Compare commits

..

107 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
ttipo 2313418e9c Merge feature/livekit-source-build: LiveKit SFU 소스 빌드 전환(Phase 1) 2026-06-25 14:39:20 +09:00
ttipo 38a3ecf8a2 build: LiveKit 을 공식 이미지 대신 소스(livekit-server/)에서 직접 빌드
- SFU 코어(pkg/sfu) 커스터마이즈를 위해 image: 참조를 build: 로 전환.
- 소스는 git subtree 로 v1.8.4 를 livekit-server/ 에 편입(이 레포에서 관리).
- 검증: 소스 빌드 이미지 99.8MB(공식 99.1MB 와 동일 수준),
  `livekit-server version 1.8.4` 정상 출력 → 코드 수정 없이 동일 동작 확인.
- dev 는 프로파일로 제외돼 무거운 Go 빌드가 돌지 않음(개발은 Cloud 유지).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:39:19 +09:00
ttipo 0da97ebd21 Squashed 'livekit-server/' content from commit 154b4d26
git-subtree-dir: livekit-server
git-subtree-split: 154b4d26b769c68a03c096124094b97bf61a996f
2026-06-25 14:35:28 +09:00
ttipo 8f49774aff Merge commit '0da97ebd2149ec2a0412a273b17270f540431d81' as 'livekit-server' 2026-06-25 14:35:28 +09:00
ttipo 79194428fc chore: 실수로 커밋된 스크린샷(image.png) 제거
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:41:00 +09:00
ttipo 954f4a6932 Merge feature/livekit-always-on: LiveKit 운영 기본 서비스 전환 2026-06-25 13:40:48 +09:00
ttipo 034afe2cf8 refactor: LiveKit 을 운영 기본 서비스로 전환(프로파일 게이트 제거)
- docker-compose.yml: livekit 의 selfhost-livekit 프로파일 제거 →
  운영(base)에서 db·redis 처럼 항상 함께 기동. Portainer 등 GUI 배포에서
  COMPOSE_PROFILES 없이도 컨테이너가 생성된다.
- docker-compose.dev.yml: livekit 에 selfhost-livekit 프로파일 추가 →
  개발에서는 가두어 미생성(개발은 LiveKit Cloud 사용 유지).

검증: prod(base) 서비스에 livekit 포함, dev(base+dev) 에는 제외 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:40:48 +09:00
ttipo 1a17540d91 Merge feature/livekit-selfhost: LiveKit 셀프호스팅(AWS EC2+도커 nginx) 구성 2026-06-25 12:59:30 +09:00
ttipo 3ec7e76f7c docs: LiveKit 셀프호스팅(AWS EC2+도커 nginx) 구성 완성
- livekit/nginx.livekit.conf.example: 도커 nginx용 wss 리버스 프록시 참조 설정 추가
  (docker DNS resolver+변수 proxy_pass로 부팅 순서 의존 제거, TLS 종단·WS 업그레이드)
- DEPLOY.md: AWS EC2+도커 nginx 4-1장 추가, 참조 설정 파일로 연결
- .env.production.example: LiveKit 섹션을 셀프호스팅 우선으로 정리, 키 생성 원라이너 추가

애플리케이션 코드 변경 없음(백엔드 토큰 오프라인 서명·프론트 env 주입뿐).
운영은 selfhost-livekit 프로파일 + EC2 보안그룹/EIP 구성으로 가동.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:30:32 +09:00
ttipo dfe2310661 Merge feature/video-meeting-host-avatar: 회의 목록 소유자 아바타+이름 표기 2026-06-24 21:18:21 +09:00
ttipo 9ad10afca6 feat: 화상회의 목록 소유자 표기를 아바타+이름으로 통일
회의방 목록의 진행자(소유자) 표기를 별 아이콘+이름에서 UserAvatar(프로필
이미지/실루엣)+이름으로 변경 — 프로젝트·업무 화면의 사람 표기 방식과 일치.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:18:13 +09:00
ttipo 960ff1d420 Merge feature/video-meeting-fixes: 화상회의 후속 보정 2026-06-24 18:17:30 +09:00
ttipo d8d2ff21f9 fix: 화상회의 후속 보정(권한 헤더·빈 상태·한글 방이름 등)
- nginx Permissions-Policy: 운영에서 camera/microphone 을 self 에 허용
  (기존 microphone=()/camera=() 가 LiveKit 장치 접근을 차단하던 문제 수정)
- 회의 로비 빈 상태/검색결과 없음을 공용 EmptyState 로 통일(내 업무·프로젝트 화면과 동일)
- 미구현인 공유 링크 안내 문구 제거(lede)
- 프리조인 "카메라·마이크 권한이 없습니다" 문구 제거
- 회의실 상단 노란색 권한 안내 배너(room-err) 제거 + 관련 prop/이벤트 정리
- 회의방 이름에 한글 허용(백엔드 DTO 2종 + 프론트 검증 정규식/문구, 공백·특수문자는 계속 차단)
- 멤버 초대 등급 셀렉트(DropdownSelect block) 라벨 좌측 정렬(버튼 기본 가운데정렬 상속 수정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:17:22 +09:00
ttipo 30b4b87368 Merge feature/video-meeting-ui: 화상회의 UI 개편 및 코드·로직 검토 반영 2026-06-24 17:57:01 +09:00
ttipo 999a8730dd feat: 화상회의 UI 전면 개편 및 코드·로직 검토 반영
디자인 시안(프로토타입)을 실제 Vue 컴포넌트로 이식하고, 코드·로직·보안
검토에서 도출한 항목을 반영한다.

UI 개편
- 컴포넌트 분리: MeetingLobby/CreateForm/Prejoin/Room/SidePanel/PeopleList/
  Chat/ControlBar/ParticipantTile/QualityBars/MeetingIcon
- 로비: 검색 툴바 + "새 회의 만들기" 모달(BaseModal) + 방 목록(삭제=휴지통)
- 프리조인: 실제 카메라/마이크 미리보기 + 초기 on/off, 실제 프로필 아바타
- 회의실: 축소 스테이지 + 썸네일 그리드(한 페이지 50명) + 페이지네이션,
  말하는 사람 하이라이트/연결 품질 막대
- 채팅: 데이터 채널 실제 구현(Shift+Enter 줄바꿈, 실제 프로필 아바타)

디자인 시스템 정합
- rem 단위/토큰 정렬, 공용 .btn/.form-*/.search/BaseModal/UserAvatar 재사용
- 회의실 다크 캔버스 토큰(--d-*, --d-red/green/amber/accent-soft) 추가

화질/성능
- 스테이지=고화질, 나머지=저화질 구독(setVideoQuality)
- 타일 backdrop-filter 제거 + 발화/품질 이벤트 전체 리렌더 제거(버벅임 완화)

코드·로직·보안 검토 반영
- 채팅 표시 이름은 서명 토큰의 name/identity 만 신뢰(클라이언트 name 사칭 차단)
- promote 는 보낸 사람 metadata role=host 일 때만 적용(인가)
- 원격 오디오를 룸 레벨에서 전원 재생(페이지 밖 참여자 무음 수정)
- 타일 언마운트 시 트랙 detach(누수 방지)
- 마이크/카메라 토글 실패 시 상태 롤백
- 입장 토큰 TTL 1h → 12h(장시간 회의 재연결 안정성)
- 채팅 길이 상한(MAX_CHAT_LENGTH)
- 토큰 metadata 에 avatarUrl 포함(참여자/채팅 아바타)

기타
- 임시 명세/디자인 참고 파일 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:56:53 +09:00
ttipo 251830d421 Merge feature/video-meeting-phase6: 화상회의 단계 6(셀프호스팅 배포 구성) 2026-06-23 22:27:10 +09:00
ttipo c5a8fc8697 feat: 화상회의 단계 6 — 셀프호스팅 배포 구성/가이드
운영에서 LiveKit(SFU)을 직접 호스팅하기 위한 구성과 배포 가이드를 추가한다.
실제 배포는 서버/도메인/TLS 가 필요하므로, 클론 후 값만 채워 배포하는 템플릿 형태.

- docker-compose.yml: livekit 서비스를 opt-in 프로파일(selfhost-livekit)로 추가.
  기존 dev/운영 동작 불변(프로파일 미지정 시 미생성). 시그널링 7880 은 expose(프록시 TLS 라우팅),
  미디어 7881/tcp·7882/udp 는 호스트 공개. 키는 LIVEKIT_KEYS env 주입.
- livekit/livekit.prod.yaml: 단일 노드 운영 설정(use_external_ip, tcp/udp 포트).
  클러스터링용 redis 블록은 비밀값이라 주석으로 두고 배포 시 사본에 채움.
- .env.production.example: LIVEKIT_* 섹션(셀프호스팅/ Cloud 양쪽 안내).
- livekit/DEPLOY.md: 사전준비/키생성/기동/프록시(wss)/방화벽/검증(load-test)/
  멀티노드 클러스터링(Redis·캐스케이딩)/운영팁.
- .gitignore: livekit/*.local.yaml(클러스터 설정 사본) 제외.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:27:10 +09:00
ttipo b40e795664 Merge feature/video-meeting-phase5: 화상회의 단계 5(부하 테스트 도구) 2026-06-23 21:05:37 +09:00
ttipo 88ef92628d feat: 화상회의 단계 5 — 부하 테스트 도구/방법론
livekit-cli(lk load-test) 기반 부하 테스트 러너와 가이드를 추가한다.
다수 publisher/subscriber 를 시뮬레이션해 SFU 부하·미디어 품질을 측정한다.

- livekit/load-test.sh: docker 로 lk load-test 실행. preset(smoke|medium|full)
  + 환경변수 조정(PUBLISHERS/SUBSCRIBERS/DURATION/RESOLUTION/LAYOUT/SIMULCAST).
  자격증명은 루트 .env 에서 읽는다.
- livekit/LOAD_TEST.md: 실행/결과 해석/합격 기준/실행 대상(Cloud=smoke만,
  셀프호스팅=full·클러스터 부하분산)/서버 모니터링/한계 정리.

검증: smoke(2 pub + 3 sub, 15s) 를 dev(LiveKit Cloud) 대상으로 실행 →
6/6 트랙 구독, 패킷 손실 0%, 정상 비트레이트 확인(도구 동작 검증 완료).
대규모(160명) 검증은 단계 6 셀프호스팅 환경에서 수행한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:05:36 +09:00
ttipo 423f2197ed Merge feature/video-meeting-phase4: 화상회의 단계 4(방 관리) 2026-06-23 20:48:52 +09:00
ttipo 54fe3d36e7 feat: 화상회의 단계 4 — 방 관리(목록·생성·예약·참여 제한)
암묵적 방 생성을 명시적 방 관리로 확장한다. (기능 완성 + 임시 UI)

백엔드
- meeting_rooms 에 scheduled_at(예약), allowed_user_ids(참여 허용 allow-list) 추가 + 마이그레이션
- GET /meetings/rooms(목록: 접근 가능/진행자 여부 포함)
- POST /meetings/rooms(생성: name+예약+허용멤버, 생성자=진행자, 이름 중복 409)
- DELETE /meetings/rooms/:id(진행자만, 아니면 403)
- 토큰 발급 시 참여 제한 enforcement: 비허용자는 AUTH_003(403).
  방이 없으면 자동 생성(공개)으로 직접 링크 입장은 유지.

프론트
- useMeeting: listRooms/createRoom/removeRoom + types/meeting.ts
- MeetingPage 로비: 방 목록(입장/삭제) + 생성 폼(예약시각·참여 제한 멤버 피커, useUser 검색 재사용) + 빠른 입장
- 화상회의_화면명세.md 단계 4 반영(범위/로비/데이터 계약/엔드포인트)

권한 모델은 기존 결정대로 "누구나 생성=진행자" 유지(전역 admin 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:48:51 +09:00
ttipo 2416f728ba Merge feature/video-meeting-phase3: 화상회의 단계 3(화면 구성, 임시 레이아웃) 2026-06-23 18:06:23 +09:00
ttipo 3c7aec86a2 feat: 화상회의 단계 3 — 화면 구성(임시 레이아웃)
진행자/승격 참여자 큰 화면 + 썸네일 + 페이지네이션 + 화면 승격을 구현한다.
(레이아웃은 임시 — UI 는 추후 개편 예정. 데이터/로직만 확정.)

- useLiveKitRoom: featuredIdentity 상태 + promote(identity) 추가.
  진행자가 데이터 채널로 승격을 브로드캐스트 → RoomEvent.DataReceived 로 전원 동기화.
- MeetingPage:
  · stage(featured: 승격>진행자>첫 참여자) 큰 화면 + 나머지 썸네일
  · 진행자에게만 썸네일 "크게"(승격) 버튼
  · 썸네일 페이지네이션(페이지당 12명) — 현재 페이지만 렌더 →
    adaptiveStream 이 안 보이는 참여자 미디어 자동 미구독(대역폭 절약)
- 화상회의_화면명세.md: 단계 2~3 반영(범위/화면 구성/데이터 계약 갱신)

백엔드 변경 없음(승격은 클라이언트 데이터 채널).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:06:23 +09:00
ttipo 9a4d287ffd Merge feature/video-meeting-phase2: 화상회의 단계 2(권한 분기) 2026-06-23 17:51:22 +09:00
ttipo f519723260 feat: 화상회의 단계 2 — 권한 분기(생성자=진행자)
방을 처음 만든 사용자를 진행자로 보고, 역할별로 LiveKit 권한을 차등 부여한다.

백엔드
- meeting_rooms 엔티티/마이그레이션: 방 이름(고유)→진행자(host) 보관
- 토큰 발급 시 역할 결정: 방이 없으면 생성자=진행자, 있으면 생성자 일치로 판정
  (동시 생성 경쟁은 name unique 충돌 후 재조회로 처리)
- grant 차등: 진행자 roomAdmin+발행/구독, 일반 발행/구독
- 역할을 토큰 metadata({"role":...})에 실어 다른 참여자도 진행자 식별
- 응답에 role 추가

프론트
- useMeeting: role 반환
- ParticipantTile: metadata 파싱 → 진행자 타일에 "진행자" 배지
- MeetingPage: 본인이 진행자면 헤더에 역할 칩 표시

방 목록/예약/참여 제한 UI 는 단계 4 로 분리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:51:21 +09:00
ttipo 85b6b65e24 Merge feature/video-meeting-phase1: 화상회의 단계 1(인프라 토대) 2026-06-23 17:40:18 +09:00
ttipo bf2321f2d5 feat: 화상회의 단계 1 — LiveKit 기반 인프라 토대
LiveKit(SFU)에 미디어 전송을 위임하고, 앱은 입장 토큰 발급 + 회의 화면만
담당하는 구조로 단계 1(기본 연결)을 구현한다.

백엔드
- meeting 모듈: POST /api/meetings/token (JWT 인증)
  livekit-server-sdk 로 입장 JWT 발급(identity=userId, name, grants:
  roomJoin/publish/subscribe/publishData, TTL 1h). 단계 2에서 역할별 분기 예정.

프론트
- useMeeting(토큰 발급), useLiveKitRoom(연결/참여자/컨트롤 상태)
- ParticipantTile(카메라/마이크 트랙 attach, 본인 거울·음소거, 이니셜 플레이스홀더)
- MeetingPage(로비 + 영상 그리드), 라우트 /meeting/:room?, 사이드바 네비 추가
- VITE_LIVEKIT_URL 주입(build arg + dev 런타임 env)

인프라/설정
- 개발 미디어 서버는 LiveKit Cloud 사용(로컬 self-host 는 WSL2/Docker Desktop
  WebRTC 네트워킹 제약으로 제외). env 만으로 운영 전환 가능.
- livekit/livekit.yaml 은 단계 6 셀프호스팅 참고용으로 보관.
- 화상회의_화면명세.md: 현재 화면 기능/구조/데이터 계약 문서(UI 개편 참고).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:40:18 +09:00
ttipo 145b4611d3 Merge tweak/detail-hide-ai-badge: 상세 화면 AI 배지 제거 2026-06-23 15:23:48 +09:00
ttipo c52630af27 refactor: 프로젝트 상세 화면에서 AI 반영 상태 배지 제거
반영 상태는 생성·설정 화면의 업로드 시점에 이미 확인 가능하므로,
조회 전용인 상세 화면 문서 목록에서는 배지를 노출하지 않는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:23:48 +09:00
ttipo 3f2a72ab31 Merge feature/attachment-ai-badge: 문서 AI 반영 상태 배지 2026-06-23 15:10:21 +09:00
ttipo e719829f0b feat: 프로젝트 문서의 AI 반영 상태 배지 표시
AI 추천이 문서 내용을 반영하지 못해도(추출 실패·미지원 형식) 추천은
정상 응답되어 사용자가 인지할 수 없던 문제를 해소한다. 문서 목록에
반영 상태 배지를 노출한다.

- ProjectAttachmentResponse.aiStatus 추가(ready/failed/unsupported)
  · ready: 추출 성공(반영 가능) · failed: 지원 형식이나 추출 실패
  · unsupported: 미지원 형식(이미지·xlsx·ppt·zip 등)
- isExtractableType + deriveAiStatus 로 상태 산출
  (추출 본문은 응답에 노출하지 않고 상태 판별에만 사용)
- listFiles 에서 extracted_text 를 명시적으로 선택(select:false 우회)
- 공용 AttachmentAiBadge 컴포넌트 추가 → 프로젝트 상세·설정 문서 목록에 적용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:10:20 +09:00
ttipo d964f9101a Merge feature/ai-read-project-docs: AI 추천에 프로젝트 문서 반영(DB 캐싱) 2026-06-23 14:54:44 +09:00
ttipo 739a5a70f3 feat: AI 업무 추천에 프로젝트 문서 내용 반영(DB 캐싱)
업무 작성 화면의 AI 추천이 프로젝트 제목·설명과 업무 제목·내용에 더해
프로젝트 첨부 문서의 내용까지 파악하도록 확장한다.

매 호출마다 문서를 재파싱하지 않도록, 업로드 시점에 1회 텍스트를
추출해 project_attachments.extracted_text 에 캐싱하고 AI 추천에서
꺼내 읽는 구조로 구현한다.

- ProjectAttachment.extractedText 컬럼 추가(text, nullable, select:false)
- extract-text.ts: txt/csv(직접)·pdf(pdf-parse)·docx(mammoth) 추출
  (문서당 8천 자 상한, 추출 불가/실패 시 null)
- addFile: 업로드 시 텍스트 추출·캐싱
- getAttachmentsTextForAi: 문서별 추출 본문을 합쳐 반환(총 1.6만 자 상한)
- suggestChecklist: [프로젝트 문서] 섹션을 프롬프트에 추가 + 시스템 규칙 보강
- 운영용 마이그레이션 추가(dev 는 synchronize 로 자동 반영)
- 추출 텍스트는 AI 내부용 — 프론트 응답 DTO 에는 노출하지 않음

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:54:29 +09:00
ttipo 87c9019446 Merge: 프로젝트 문서 첨부(생성/편집/상세) 2026-06-23 14:32:35 +09:00
ttipo 8fc9287747 feat: 프로젝트 문서 첨부(생성/편집/상세)
프로젝트에 문서 파일을 첨부할 수 있도록 추가(업무 첨부 인프라·업로드 설정 재사용).

백엔드:
- ProjectAttachment 엔티티 + 마이그레이션(project_attachments)
- 업로드(관리자)/삭제(관리자)/다운로드(멤버) 엔드포인트, 멀터 디스크 저장
- 프로젝트 상세 응답에 attachments 포함

프론트:
- 생성 화면: 파일 staged 후 생성 직후 업로드
- 편집(설정) 화면: 기존 문서 목록 + 추가/삭제(즉시), 폼 미저장 보존
- 프로젝트 상세: 문서 목록(다운로드 전용)
- useProject.uploadFile/removeFile, ApiProjectAttachment 타입

권한: 업로드/삭제=관리자, 조회/다운로드=멤버. 형식/용량은 업무 첨부와 동일(25MB).
런타임 검증: 업로드·목록·다운로드·삭제 + 비관리자 403 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:32:35 +09:00
ttipo 916dd49887 Merge: 일정 참석자 알림(지정/수정/삭제) 2026-06-23 13:56:04 +09:00
ttipo 42bf97636a feat: 일정 참석자 알림(지정/수정/삭제)
일정 CRUD 시 참석자에게 인앱 알림 발송(기존 NotificationService + SSE 재사용).

- 생성: 참석자로 지정된 사용자에게 schedule.invited (전 직원 일정은 개별 알림 생략)
- 수정: 신규 참석자 → schedule.invited, 기존 참석자 → schedule.updated
- 삭제: 참석자에게 schedule.removed (삭제 전 참석자 확보)
- 행위자 본인은 자동 제외, payload(actorName/eventTitle/start), 클릭 시 /schedule 이동
- NotificationType(백/프론트) 3종 추가 + notification.store 렌더 케이스

런타임 검증: 지정/수정/삭제 알림이 참석자에게만 정상 도달.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:56:03 +09:00
ttipo a344945e4e Merge: 사이드바 순서 변경 + 기본 진입 일정으로 2026-06-23 13:39:38 +09:00
ttipo 34b5b1d552 feat: 사이드바 순서 변경 + 기본 진입 경로 일정으로
- 사이드바 nav 순서: 일정 → 내 업무 → 프로젝트
- 기본 진입을 /schedule 로: 루트 redirect, 로그인 후 이동, 로그인 상태에서
  로그인/가입 진입 가드, 소셜 로그인(구글/카카오) 콜백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:39:38 +09:00
ttipo ad950e88ad Merge: 일정 주간/월간 뷰 + 내 일정 필터 + 부가 개선
feature/schedule-month-view 머지 — 주간/월간 전환, 월간 달력 그리드(+N 팝오버),
'내 일정' 필터(참석자 기준), 필수 입력 표기·참석자 필수, 월간 바 두께.
2026-06-23 13:34:03 +09:00
ttipo 511efec64d feat: 일정 — 내 일정 필터 + 월간 '+N' 팝오버 + 필수표기/참석자 필수
- '내 일정' 토글 추가(참석자 기준: 전 직원 또는 참석자에 본인 포함). 주간/월간 공통
- 월간 '+N' 클릭 시 그날 전체 일정 팝오버(Teleport, 바깥클릭/ESC 닫기, 클릭→상세)
- 일정/카테고리 모달 필수 입력 표기(*) — 제목·유형·시작일·종료일·시간·참석자, 카테고리 이름
  (.form-label .req 전역 스타일 추가)
- 참석자 필수화(폼 검증: 전 직원 또는 1명 이상)
- 월간 일정바 두께 소폭 증가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:33:52 +09:00
ttipo 4ff746638c feat: 일정 주간/월간 뷰 전환 추가
상단 토글(주간/월간)로 보기 전환. 월간은 전통 달력 그리드(주×요일).

- scheduleDate: firstOfMonth/addMonths/monthLabel/monthGridWeeks(+MonthDay) 추가
- schedule.store: viewMode + anchor 기반으로 주간/월간 범위·라벨·네비 일원화
  (goPrev/goNext 가 모드별로 주/월 이동, setViewMode 시 범위 재조회)
- ScheduleMonthGrid: 주 단위 다중일 막대(레인 패킹) + 초과 '+N',
  카테고리 색·유형 필터·오늘/주말/이전·다음달 표기, 막대 클릭 시 상세 패널
- SchedulePage: SegmentedTabs 토글 + 주간/월간 조건부 렌더

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:45:52 +09:00
ttipo 3f134ccca3 Merge: 일정 타임라인 빈 상태 표 영역 가득 채우기
tweak/schedule-empty-fill 머지.
2026-06-23 12:05:17 +09:00
ttipo cc06fd9816 style: 일정 타임라인 빈 상태가 표 영역을 가득 채우도록
카테고리가 없을 때 안내 문구를 상단에 작게 두지 않고, 본문(min-height:100%)과
.tl-empty(flex:1)로 표 영역 전체 높이를 채워 중앙 배치.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:03:18 +09:00
ttipo f2ead261c8 Merge: 일정 기본(시드) 카테고리 제거
chore/schedule-remove-default-categories 머지 — 기본 카테고리 시드 삭제(사용자 추가 방식).
2026-06-23 11:49:19 +09:00
ttipo 1ad95ff7e8 chore: 일정 기본(시드) 카테고리 제거
기본 카테고리 12종 onModuleInit 시드를 삭제 — 카테고리는 사용자가 직접 추가한다.
(dev DB 의 기존 시드 카테고리/일정은 별도로 정리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:47:13 +09:00
ttipo b9d1990162 Merge: 토큰 갱신/로그인 유지 수정 + 일정/업무 UI 다듬기
fix/auth-token-refresh 머지:
- 401 자동 refresh 가 /auth/me 에서 동작하도록 수정(짧은 로그아웃 해결)
- keepLogin 구현(persistent 세션 플래그·세션/영속 쿠키)
- 일정 모달 placeholder·참석자 콤보 UI, AvatarStack 공용화(일정 참석자·업무 담당자)
2026-06-23 11:33:42 +09:00
ttipo a094283e62 style: 일정/업무 UI 다듬기 — placeholder·참석자 콤보·AvatarStack 공용화
- 새 일정 모달 placeholder 를 프로젝트 기존 톤("~을 입력하세요/선택하세요")으로 통일
- 참석자 등록을 업무 생성의 담당자 등록과 동일한 콤보(검색 입력+후보 드롭다운+선택 칩)로 개편
  ('전 직원'은 드롭다운 항목/단일 칩으로 처리)
- 아바타 스택+오버플로(+N)+팝오버 목록을 공용 AvatarStack 으로 추출,
  일정 상세 참석자 + 업무 상세 담당자 카드에 적용(담당자 팝오버에 역할 표시)
- 사용처가 사라진 ScheduleAvatar 삭제(UserAvatar/AvatarStack 으로 일원화)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:32:58 +09:00
ttipo b8756d34ab fix: 토큰 갱신/로그인 유지(keepLogin) 정상화
세션이 짧게 유지되고 '로그인 유지'가 동작하지 않던 문제 수정.

원인1(프론트): 401 자동 refresh 가 '/auth/' 포함 URL을 모두 제외해,
부트스트랩 /auth/me 가 access 만료(15분)로 401 이어도 refresh 를 시도하지 않아
새로고침/재방문 시 로그아웃됨.
 - useApi 인터셉터: refresh-재시도 제외 대상을 /auth/refresh·/auth/login 으로만 한정
   → /auth/me 등은 access 만료 시 refresh 후 재시도되어 세션 복원

원인2(백엔드): keepLogin 값이 무시되어 쿠키 영속 여부에 반영되지 않음.
 - RefreshSession 에 persistent 플래그 추가(마이그레이션, default true)
 - 로그인 시 keepLogin 저장, refresh 회전 시에도 그대로 유지
 - setAuthCookies: persistent=true 면 maxAge(영속), false 면 세션 쿠키
 - 소셜 로그인은 체크박스가 없어 영속(true) 기본

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:56:25 +09:00
ttipo 0cdd2bb98e Merge: 보고서 모달·일정 화면 다듬기 + 일정 컴포넌트 rem 통일
tweak/schedule-polish 머지 — 보고서 모달(전체탭 제거·상태버튼 축소·푸터 배경),
일정 화면 여백/마진 정리, 일정 하위 컴포넌트 px→rem 일괄 변환.
2026-06-23 10:36:50 +09:00
ttipo 7568637de2 style: 보고서 모달·일정 화면 다듬기 + 일정 컴포넌트 rem 통일
- 보고서 모달: '전체' 탭 제거(기본=항목 있는 첫 분류), 상태 드롭다운 크기 축소,
  푸터 배경을 다른 모달(.bm-foot)과 동일한 #fafbfc 로 통일
- 일정 화면: 콘텐츠 영역 상하좌우 여백 1.5rem 통일, 툴바 가로 여백 정렬 +
  전역 .toolbar 의 margin-bottom 상속 차단(상단 여분 마진 제거)
- 일정 하위 컴포넌트 px→rem 일괄 변환(보더 width·box-shadow 는 px 유지),
  ScheduleAvatar 도 size 를 rem 렌더 → '전 직원' 표식과 아바타 크기 정합 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:38:59 +09:00
ttipo 61d247de08 Merge: 프론트 공용화 리팩터 (Phase 0-4)
refactor/frontend-commonization 머지 — useClickOutside/useDropdownMenu 훅,
BaseModal/SegmentedTabs/LoadMore/EmptyState 공용 컴포넌트, .form-*/.empty-state
전역 클래스, 작업상태 SSOT(workStatus) + 업무상태 'todo' 라벨 '대기' 통일.
2026-06-23 00:10:16 +09:00
ttipo ddf94b188b Merge: 전사 공유 일정(주간 캘린더) 기능
feature/schedule-calendar 머지 — 백엔드 Schedule 모듈 + 프론트 일정 화면
(주간 타임라인·카테고리/일정 CRUD·상세 패널·모달) + UI 다듬기.
공용화 리팩터(refactor/frontend-commonization)는 별도 브랜치로 분리 유지.
2026-06-23 00:09:12 +09:00
ttipo daff61c85e refactor: 업무 상태 'todo' 라벨을 '대기' 로 통일 (Phase 4)
todo 표기가 화면마다 '할 일'(taskStatusLabel) vs '대기'(ProjectDetail 배지)로
갈리던 불일치 해소. SSOT(format.taskStatusLabel)를 '대기' 로 통일하고
ProjectDetailPage 의 자체 STATUS_LABEL 제거 → taskStatusLabel 사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:02:42 +09:00
ttipo 3380bbdcff refactor: 작업자 진행상태(대기/진행중/완료) 라벨·색 SSOT 통합 (Phase 4)
StatusSelect 와 업무 상세에 각각 중복 정의되던 WORK_STATUS_META/ORDER 를
shared/constants/workStatus.ts 단일 소스로 추출(값 동일, 표시 변화 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:00:21 +09:00
ttipo 21cabbe20b refactor: ScheduleAvatar 를 UserAvatar 어댑터로 정리 (Phase 3-4)
ScheduleAvatar 가 복제하던 이미지/실루엣 렌더링을 제거하고, 공용 UserAvatar 를
size(px)로 감싸는 얇은 래퍼로 전환. 아바타 표시 로직을 UserAvatar 단일 소스로 통일
(.sa 클래스는 상세 패널 스택 CSS 훅으로 유지, 사용처 변경 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:57:22 +09:00
ttipo d3e2861440 refactor: 빈/상태 메시지 박스 EmptyState 로 통일 (Phase 3-3)
목록 로딩·빈 상태 박스(state-msg/feed-state/list-empty)를 전역 .empty-state
카드 스타일 + EmptyState 컴포넌트로 통일. 5개 페이지(프로젝트 목록/상세,
내 업무, 활동, 멤버)의 중복 박스 CSS 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:56:07 +09:00
ttipo e7760e9ddc refactor: 더보기 버튼 LoadMore 컴포넌트로 통일 (Phase 3-2)
목록 페이지네이션 '더보기' 버튼 마크업/문구를 LoadMore 로 추출(전역 .load-more 사용).
- 프로젝트 목록/상세, 내 업무, 프로젝트 활동 4곳 적용
- 활동 화면의 비표준 .more-row/.more-btn 제거하고 표준 .load-more 로 통일
  ('더 보기' → '더보기' 문구도 통일)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:51:45 +09:00
ttipo d3a916dbd7 refactor: 세그먼트 토글 SegmentedTabs 컴포넌트로 통일 (Phase 3-1)
3개 페이지(활동/프로젝트 상세/멤버)에 반복되던 .segmented 버튼 마크업을
options 기반 SegmentedTabs 로 추출(전역 .segmented 스타일은 그대로 사용).
수량(.n) 표기는 option.count 로 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:49:09 +09:00
ttipo 7425d57134 refactor: Teleport 드롭다운 위치/토글 로직 useDropdownMenu 로 통합 (Phase 2)
DropdownSelect·StatusSelect 에 중복되던 fixed 위치 계산·open 토글·
바깥클릭/ESC/스크롤 닫기 로직을 useDropdownMenu 훅으로 추출.

- composables/useDropdownMenu.ts 신설 (align/matchWidth/gap/esc 옵션)
- DropdownSelect, StatusSelect 가 훅을 사용하도록 정리
- 절대배치 메뉴(일정 멀티셀렉트/유형필터/참석자 스택)는 Phase 0 useClickOutside 로 이미 공유

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:44:48 +09:00
ttipo 0c4f0817e6 refactor: 멤버 초대 모달을 BaseModal 로 전환 (Phase 1b)
- 중복 모달 셸/헤더/푸터/필드 CSS(~150줄) 제거 → BaseModal + 전역 .form-*
- BaseModal 에 subtitle 슬롯 + 푸터 버튼 아이콘 정렬 추가
- 본문 폼은 display:contents 로 BaseModal 본문 간격 상속,
  고유 요소(자동완성/역할 힌트/푸터 노트)만 .invite-body 로 재스코프

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:41:41 +09:00
ttipo 5f8b2597bf refactor: 공용 폼 모달 BaseModal + 전역 .form-* 필드 클래스 (Phase 1a)
- components/common/BaseModal.vue 신설: backdrop+헤더(제목/닫기)+본문/푸터 슬롯,
  Teleport·ESC·바깥클릭 닫기 내장. 전역 .modal* 와 충돌 없게 bm- 접두 클래스 사용
- relay.css 에 .form-field/-label/-row/-input/-textarea/-select(+wrap/chev) 표준 추가
- 일정·카테고리 모달을 BaseModal + .form-* 로 전환(중복 모달/필드 CSS 제거,
  푸터 버튼은 전역 .mbtn 사용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:35:33 +09:00
ttipo 92ed6c332f refactor: 바깥클릭/ESC 닫기 공용 훅 useClickOutside 도입 (Phase 0)
11곳에 복붙되던 document 리스너 등록/해제·바깥클릭·ESC·스크롤 닫기 로직을
useClickOutside 훅으로 통합. 재사용 드롭다운 5종에 우선 적용.

- composables/useClickOutside.ts 신설 (event/esc/closeOnScroll 옵션)
- 적용: DropdownSelect, StatusSelect, ScheduleTypeFilter,
  ScheduleDetailPanel(참석자 스택), ScheduleEventModal(참석자 멀티셀렉트)
- 모달 ESC·페이지 레벨 오버레이 조정 로직은 후속 단계(BaseModal)에서 처리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:31:45 +09:00
ttipo d5b54c1afb style: 일정 화면 UI 다듬기 (기존 디자인 규격에 통일)
- 툴바 '오늘' 버튼 제거, 컨트롤 간격을 전역 .toolbar(0.625rem)와 통일
- 타임라인 레인 hover 편집/삭제 버튼 제거(카테고리 관리는 유형 필터에서)
- 유형 필터 버튼을 기존 DropdownSelect pill 디자인으로 통일 + 수량 뱃지 제거
- '새 일정' 버튼의 스코프 .btn 재정의 제거 → 전역 .btn primary 표준 사용
- 일정/카테고리 모달을 사이트 폼 모달 규격(rem + 멤버 초대 모달)으로 재정렬,
  라벨 좌측정렬, 취소/저장 버튼을 전역 .mbtn 으로 통일
- 참석자 아바타를 공용 UserAvatar 방식(이미지/실루엣)으로 교체,
  '전 직원' 표식 크기를 아바타와 동일(20px)하게 고정(루트 14px rem 보정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:24:33 +09:00
ttipo 346ec75553 feat: 전사 공유 일정(주간 캘린더) 기능 추가
상단 nav 독립 메뉴 '일정' — 주간 타임라인(간트형) 캘린더.
카테고리=레인, 일정=막대(다중일 지원), 우측 상세 패널, 일정/카테고리 CRUD.

백엔드(Schedule 모듈):
- ScheduleCategory/ScheduleEvent 엔티티 + 참석자(다대다) + 작성자
- 카테고리/일정 CRUD, 기간 겹침 조회, 참석자 풀(전사 사용자)
- 기본 카테고리 12종 onModuleInit 멱등 시드
- 인증 사용자 누구나 관리(전역 일정, 글로벌 관리자 개념 없음)
- 운영 마이그레이션(AddSchedule) 추가, @nestjs/schedule 임포트는 CronModule 로 alias

프론트:
- /schedule 라우트 + 사이드바 nav 항목
- 주간 네비(이전/다음/오늘), 유형 필터·카테고리 관리 드롭다운
- 타임라인/상세 패널/일정 모달/카테고리 모달(hue 슬라이더)/참석자 멀티셀렉트
- schedule.store + useSchedule + scheduleDate 유틸

런타임 검증: dev 부팅·시드(12건)·라우트 매핑 정상, 인증 게이트(401),
일정 생성/조회/수정/삭제 + 참석자/전직원/다중일 스모크 전부 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:10 +09:00
ttipo 62df92e6eb chore: 디자인 시안 HTML(단일 파일) gitignore 패턴 추가
시안 네이밍이 '(단일 파일)' 로도 들어와 기존 '단독' 패턴에 안 걸리던 것 보완.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:23:45 +09:00
552 changed files with 155578 additions and 1209 deletions
+37
View File
@@ -52,3 +52,40 @@ CORS_ORIGIN=http://localhost:5273
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
# ==========================================
# 화상회의 (LiveKit Cloud)
# ==========================================
# 개발은 LiveKit Cloud(무료 dev)를 사용한다. https://cloud.livekit.io 에서 프로젝트 생성 후
# Settings → Keys 에서 발급된 값을 채운다. 코드 변경 없이 env 만으로 동작한다.
# LIVEKIT_API_KEY / LIVEKIT_API_SECRET : 백엔드 입장 토큰(JWT) 서명용 (절대 커밋 금지)
# LIVEKIT_WS_URL : 브라우저 → LiveKit 접속 URL. Cloud 는 wss://<project>.livekit.cloud
# (build arg/런타임 env 로 VITE_LIVEKIT_URL 에 주입)
# LIVEKIT_URL : 백엔드 → LiveKit 서버 API(RoomService 등) 주소. 보통 LIVEKIT_WS_URL 과 동일.
# (단계 1 토큰 발급은 오프라인이라 아직 미사용)
# 운영: .env.production 에 운영용 키/URL 만 넣으면 그대로 전환된다(셀프호스팅 시 자체 URL/키 사용).
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
+40
View File
@@ -52,3 +52,43 @@ CORS_ORIGIN=http://localhost:5173
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
# ==========================================
# 화상회의 (LiveKit) — 셀프호스팅(기본) / Cloud(대안)
# ==========================================
# [셀프호스팅] 운영 권장. 절차: livekit/DEPLOY.md (단일노드 3장, AWS EC2+도커 nginx 4-1장).
# - selfhost-livekit 프로파일로 컨테이너 기동:
# docker compose --profile selfhost-livekit --env-file .env.production up -d --build
# - 리버스 프록시(wss 종단)는 livekit/nginx.livekit.conf.example 참고.
# - 키 생성(절대 Cloud 키 재사용 금지):
# LIVEKIT_API_KEY=$(openssl rand -hex 8)
# LIVEKIT_API_SECRET=$(openssl rand -hex 32) # 최소 32자
# - LIVEKIT_WS_URL : 브라우저 접속(리버스 프록시 TLS) → wss://livekit.<운영도메인>
# - LIVEKIT_URL : 백엔드 → LiveKit 내부 API(도커 네트워크) → http://livekit:7880
# [Cloud] 대안. LIVEKIT_WS_URL/URL 을 wss://<project>.livekit.cloud 로, 키를 Cloud 값으로
# 채우고 selfhost-livekit 프로파일은 켜지 않는다.
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
+10
View File
@@ -53,3 +53,13 @@ Thumbs.db
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
frontend/*단독*.html
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=
+1884 -111
View File
File diff suppressed because it is too large Load Diff
+8
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,23 +41,29 @@
"@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",
"livekit-server-sdk": "^2.15.5",
"mammoth": "^1.12.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"pdf-parse": "^2.4.5",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"web-push": "^3.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
+10 -2
View File
@@ -1,7 +1,7 @@
import { Logger, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleModule as CronModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { createKeyv } from '@keyv/redis';
@@ -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';
@@ -18,10 +19,13 @@ import { ActivityModule } from './modules/activity/activity.module';
import { NotificationModule } from './modules/notification/notification.module';
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: [
ScheduleModule.forRoot(),
CronModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
@@ -90,6 +94,7 @@ import { ProjectModule } from './modules/project/project.module';
};
},
}),
StorageModule,
HealthModule,
UserModule,
AuthModule,
@@ -98,6 +103,9 @@ import { ProjectModule } from './modules/project/project.module';
NotificationModule,
AiModule,
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,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 전사 공유 일정(캘린더) — 카테고리/일정/참석자 테이블 추가.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// (기본 카테고리 시드는 ScheduleService.onModuleInit 가 멱등 처리하므로 여기서 넣지 않는다.)
export class AddSchedule1782300000000 implements MigrationInterface {
name = 'AddSchedule1782300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 카테고리(레인)
await queryRunner.query(
`CREATE TABLE "schedule_categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "label" character varying NOT NULL, "color" character varying NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_schedule_categories" PRIMARY KEY ("id"))`,
);
// 일정(이벤트)
await queryRunner.query(
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "time" character varying NOT NULL DEFAULT '', "place" character varying, "description" text, "all_hands" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "category_id" uuid, "created_by" uuid, CONSTRAINT "PK_schedule_events" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_dates" ON "schedule_events" ("start_date", "end_date")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_category" ON "schedule_events" ("category_id")`,
);
// 참석자(다대다 조인)
await queryRunner.query(
`CREATE TABLE "schedule_event_attendees" ("event_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_schedule_event_attendees" PRIMARY KEY ("event_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_event" ON "schedule_event_attendees" ("event_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_user" ON "schedule_event_attendees" ("user_id")`,
);
// FK
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_category" FOREIGN KEY ("category_id") REFERENCES "schedule_categories"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_created_by" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_event" FOREIGN KEY ("event_id") REFERENCES "schedule_events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_user"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_event"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_created_by"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_category"`,
);
await queryRunner.query(`DROP TABLE "schedule_event_attendees"`);
await queryRunner.query(`DROP TABLE "schedule_events"`);
await queryRunner.query(`DROP TABLE "schedule_categories"`);
}
}
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 로그인 유지(keepLogin) — refresh 세션에 persistent 플래그 추가.
// true(기본): 영속 쿠키(refresh TTL), false: 세션 쿠키(브라우저 종료 시 만료).
// 기존 세션은 기존 동작(영속) 유지를 위해 default true.
export class AddRefreshSessionPersistent1782400000000 implements MigrationInterface {
name = 'AddRefreshSessionPersistent1782400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_sessions" ADD "persistent" boolean NOT NULL DEFAULT true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_sessions" DROP COLUMN "persistent"`,
);
}
}
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 문서 첨부 — project_attachments 테이블 추가.
// (업무 첨부 attachments 와 동일 구조, project_id FK)
export class AddProjectAttachments1782500000000 implements MigrationInterface {
name = 'AddProjectAttachments1782500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "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, "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 "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`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_uploader"`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_project"`,
);
await queryRunner.query(`DROP TABLE "project_attachments"`);
}
}
@@ -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,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 첨부 문서의 추출 본문 캐시 컬럼 추가.
// 업로드 시 1회 추출해 보관하고, AI 업무 추천에서 꺼내 읽는다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddProjectAttachmentExtractedText1782600000000 implements MigrationInterface {
name = 'AddProjectAttachmentExtractedText1782600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD "extracted_text" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP COLUMN "extracted_text"`,
);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방(meeting_rooms) 테이블 추가 — 방 이름(고유)과 진행자(host) 보관.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRooms1782700000000 implements MigrationInterface {
name = 'AddMeetingRooms1782700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "meeting_rooms" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "host_id" uuid, CONSTRAINT "PK_meeting_rooms" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD CONSTRAINT "FK_meeting_rooms_host" FOREIGN KEY ("host_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP CONSTRAINT "FK_meeting_rooms_host"`,
);
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
await queryRunner.query(`DROP TABLE "meeting_rooms"`);
}
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방에 예약 시각/참여 허용 사용자 컬럼 추가 (단계 4: 방 관리).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRoomFields1782800000000 implements MigrationInterface {
name = 'AddMeetingRoomFields1782800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "scheduled_at" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "allowed_user_ids" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "allowed_user_ids"`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "scheduled_at"`,
);
}
}
@@ -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)만 저장하고
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
+66 -15
View File
@@ -84,10 +84,12 @@ const SUGGEST_SYSTEM_PROMPT = [
'',
'[규칙]',
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
'- 출력은 완결된 단일 JSON 객체여야 합니다. 모든 객체는 중괄호 {}로 감싸고, 배열 []·문자열 "" 을 정확히 닫으세요. 중간에 끊지 마세요.',
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)',
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
'- 업무·프로젝트 맥락에 구체적으로 맞추고 일반론은 피하세요.',
'- [프로젝트 문서] 섹션이 주어지면(첨부 문서에서 추출한 내용) 그 내용을 적극 반영해 더 구체적으로 제안하세요.',
'',
'[형식]',
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
@@ -199,9 +201,16 @@ export class AiService {
// 프로젝트 정보(이름/설명) — 인증 사용자면 열람 가능. 없으면 404.
const project = await this.projectService.findOne(input.projectId, userId);
const content = (input.content ?? []).join('\n').trim() || '(없음)';
// 프로젝트 첨부 문서의 캐싱된 추출 본문(업로드 시 보관) — 있으면 추천에 반영
const docsText = await this.projectService.getAttachmentsTextForAi(
input.projectId,
);
const userMsg = [
`[프로젝트] 표시 제목: ${project.name}`,
`설명: ${project.description ?? '(없음)'}`,
...(docsText
? ['', '[프로젝트 문서] (첨부 문서에서 추출한 내용)', docsText]
: []),
'',
`[업무] 제목: ${input.title}`,
'내용:',
@@ -213,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 };
@@ -263,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',
+35 -12
View File
@@ -60,21 +60,23 @@ export class AuthController {
};
}
// access/refresh 토큰을 쿠키로 설정
// access/refresh 토큰을 쿠키로 설정.
// persistent=true 면 maxAge 부여(영속 쿠키), false 면 maxAge 생략(세션 쿠키 — 브라우저 종료 시 만료).
private setAuthCookies(
res: Response,
accessToken: string,
refreshToken: string,
persistent: boolean,
): void {
res.cookie('access_token', accessToken, {
...this.cookieBase(),
path: '/',
maxAge: ACCESS_MAX_AGE,
...(persistent ? { maxAge: ACCESS_MAX_AGE } : {}),
});
res.cookie('refresh_token', refreshToken, {
...this.cookieBase(),
path: REFRESH_COOKIE_PATH,
maxAge: REFRESH_MAX_AGE,
...(persistent ? { maxAge: REFRESH_MAX_AGE } : {}),
});
}
@@ -88,9 +90,22 @@ export class AuthController {
}
// PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통)
private async loginWith(res: Response, user: PublicUser): Promise<void> {
const tokens = await this.authService.issueTokensForNewSession(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
// keepLogin: 로그인 유지 여부(영속 쿠키 vs 세션 쿠키)
private async loginWith(
res: Response,
user: PublicUser,
keepLogin: boolean,
): Promise<void> {
const tokens = await this.authService.issueTokensForNewSession(
user,
keepLogin,
);
this.setAuthCookies(
res,
tokens.accessToken,
tokens.refreshToken,
tokens.persistent,
);
}
@Post('signup')
@@ -171,7 +186,8 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.validateUser(dto);
await this.loginWith(res, user);
// keepLogin 미지정(API 직접 호출 등)이면 세션 쿠키(false)로 안전하게 처리
await this.loginWith(res, user, dto.keepLogin ?? false);
return user;
}
@@ -192,8 +208,9 @@ export class AuthController {
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/projects`);
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
await this.loginWith(res, req.user as PublicUser, true);
res.redirect(`${this.webUrl()}/schedule`);
}
// ── 카카오 OAuth ──
@@ -213,8 +230,9 @@ export class AuthController {
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/projects`);
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
await this.loginWith(res, req.user as PublicUser, true);
res.redirect(`${this.webUrl()}/schedule`);
}
@Post('logout')
@@ -250,7 +268,12 @@ export class AuthController {
const tokens = await this.authService.rotateTokens(
cookies?.refresh_token,
);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
this.setAuthCookies(
res,
tokens.accessToken,
tokens.refreshToken,
tokens.persistent,
);
return null;
} catch (e) {
// 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거
+19 -6
View File
@@ -31,6 +31,11 @@ export interface TokenPair {
refreshToken: string;
}
// 세션 토큰 + 쿠키 영속 여부(keepLogin) — 컨트롤러가 쿠키 maxAge 결정에 사용
export interface SessionTokens extends TokenPair {
persistent: boolean;
}
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
export interface SignupResult {
email: string;
@@ -226,8 +231,12 @@ export class AuthService {
return UserService.toPublic(user);
}
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급
async issueTokensForNewSession(user: PublicUser): Promise<TokenPair> {
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급.
// keepLogin(persistent): true 면 영속 쿠키, false 면 세션 쿠키로 발급되도록 세션에 기록.
async issueTokensForNewSession(
user: PublicUser,
keepLogin: boolean,
): Promise<SessionTokens> {
// 만료된 이 사용자의 세션 정리(누적 방지)
await this.refreshRepo
.createQueryBuilder()
@@ -243,15 +252,17 @@ export class AuthService {
currentJti: jti,
prevJti: null,
prevAt: null,
persistent: keepLogin,
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS),
}),
);
return this.signTokens(user.id, user.email, session.id, jti);
const tokens = await this.signTokens(user.id, user.email, session.id, jti);
return { ...tokens, persistent: keepLogin };
}
// refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리,
// 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401.
async rotateTokens(refreshToken: string | undefined): Promise<TokenPair> {
async rotateTokens(refreshToken: string | undefined): Promise<SessionTokens> {
if (!refreshToken) throw new UnauthorizedException();
let payload: JwtPayload;
try {
@@ -281,12 +292,13 @@ export class AuthService {
session.currentJti = newJti;
session.expiresAt = new Date(Date.now() + REFRESH_SESSION_TTL_MS);
await this.refreshRepo.save(session);
return this.signTokens(
const tokens = await this.signTokens(
session.user.id,
session.user.email,
session.id,
newJti,
);
return { ...tokens, persistent: session.persistent };
}
// 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X)
@@ -295,12 +307,13 @@ export class AuthService {
session.prevAt &&
Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS
) {
return this.signTokens(
const tokens = await this.signTokens(
session.user.id,
session.user.email,
session.id,
session.currentJti,
);
return { ...tokens, persistent: session.persistent };
}
// 3) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃)
@@ -34,6 +34,11 @@ export class RefreshSession {
@Column({ name: 'prev_at', type: 'timestamptz', nullable: true })
prevAt!: Date | null;
// 로그인 유지(keepLogin) 여부 — true 면 영속 쿠키(refresh TTL 만큼), false 면
// 세션 쿠키(브라우저 종료 시 만료). refresh 회전 시에도 이 값을 따라 쿠키를 재발급한다.
@Column({ type: 'boolean', default: true })
persistent!: boolean;
// 세션 만료(refresh TTL) — 만료분 정리용
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@@ -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;
}
@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Length,
Matches,
} from 'class-validator';
// 화상회의 방 생성 요청
export class CreateMeetingRoomDto {
@ApiProperty({
description: '방 이름(한글/영문/숫자/-/_ 허용, 공백 불가, 고유)',
example: '주간-회의',
})
@IsString()
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
@Matches(/^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]+$/, {
message:
'방 이름은 한글, 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다. (공백 불가)',
})
name!: string;
@ApiPropertyOptional({
description: '예약 시각(ISO 8601). 미지정 시 즉시 사용 가능한 방',
example: '2026-07-01T10:00:00.000Z',
})
@IsOptional()
@IsISO8601({}, { message: '예약 시각 형식이 올바르지 않습니다.' })
scheduledAt?: string;
@ApiPropertyOptional({
description: '참여 허용 사용자 ID 목록. 비우면 전체 공개',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(160, { message: '참여 허용 인원은 최대 160명입니다.' })
@IsUUID('4', { each: true, message: '사용자 ID 형식이 올바르지 않습니다.' })
allowedUserIds?: string[];
}
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length, Matches } from 'class-validator';
// 화상회의 입장 토큰 발급 요청 — 참여할 방의 입장 코드를 전달한다.
export class CreateMeetingTokenDto {
@ApiProperty({
description: '참여할 회의 방 입장 코드(영문/숫자)',
example: 'a1b2c3d4e5',
})
@IsString()
@Length(6, 32, { message: '입장 코드 형식이 올바르지 않습니다.' })
@Matches(/^[A-Za-z0-9]+$/, {
message: '입장 코드 형식이 올바르지 않습니다.',
})
code!: string;
}
@@ -0,0 +1,5 @@
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정).
// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다.
export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {}
@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 화상회의 방 — 방 이름과 진행자(생성자)를 보관한다.
// 단계 2(경량): 방 이름으로 토큰을 처음 발급받은 사용자가 진행자(host)가 되고,
// 이후 입장자는 일반 참여자가 된다. (방 목록/예약/참여 제한 UI 는 단계 4)
@Entity('meeting_rooms')
export class MeetingRoom {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준.
// 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다.
@Index({ unique: true })
@Column()
code!: string;
// 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다.
@Column()
name!: string;
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'host_id' })
host!: Relation<User> | null;
// 예약 시각(선택) — 표시용. 현재는 시간 전 입장을 막지 않는다.
@Column({ name: 'scheduled_at', type: 'timestamp', nullable: true })
scheduledAt!: Date | null;
// 참여 허용 사용자 ID 목록(선택) — 비어있으면(null) 전체 공개.
// 값이 있으면 진행자 + 목록에 포함된 사용자만 입장 가능.
@Column({ name: 'allowed_user_ids', type: 'simple-array', nullable: true })
allowedUserIds!: string[] | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
MeetingService,
type MeetingTokenResult,
type MeetingRoomResponse,
} 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('화상회의')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('meetings')
export class MeetingController {
constructor(private readonly meetingService: MeetingService) {}
@Get('rooms')
@ApiOperation({ summary: '회의 방 목록' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
listRooms(@CurrentUser() user: PublicUser): Promise<MeetingRoomResponse[]> {
return this.meetingService.listRooms(user.id);
}
@Post('rooms')
@ApiOperation({ summary: '회의 방 생성(생성자=진행자)' })
@ApiResponse({ status: 201, description: '생성 성공' })
createRoom(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
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: '회의 방 삭제(진행자만)' })
@ApiResponse({ status: 204, description: '삭제 성공' })
async deleteRoom(
@CurrentUser() user: PublicUser,
@Param('roomId', ParseUUIDPipe) roomId: string,
): Promise<void> {
await this.meetingService.deleteRoom(user.id, roomId);
}
@Post('token')
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
issueToken(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingTokenDto,
): Promise<MeetingTokenResult> {
return this.meetingService.issueToken(user, dto.code);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
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, User])],
controllers: [MeetingController],
providers: [MeetingService],
exports: [MeetingService],
})
export class MeetingModule {}
@@ -0,0 +1,291 @@
import { randomBytes } from 'crypto';
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/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';
// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함
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;
}
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
export interface MeetingTokenResult {
token: string; // LiveKit 입장 JWT
roomName: string;
identity: string; // 참여자 식별자(사용자 ID)
role: MeetingRole; // 이번 입장자의 역할
}
// 화상회의 서비스(단계 2: 권한 분기) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 역할별 권한이 담긴 토큰을 만든다.
// 역할: 방을 처음 만든 사용자가 진행자(host), 이후 입장자는 일반 참여자(participant).
@Injectable()
export class MeetingService {
private readonly logger = new Logger(MeetingService.name);
private readonly apiKey: string;
private readonly apiSecret: string;
constructor(
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();
if (!this.enabled) {
this.logger.warn(
'LIVEKIT_API_KEY/LIVEKIT_API_SECRET 미설정 — 화상회의 토큰 발급 비활성화',
);
}
}
get enabled(): boolean {
return this.apiKey.length > 0 && this.apiSecret.length > 0;
}
// LiveKit 입장 토큰 발급 — 역할에 따라 권한(grant)을 차등 부여한다.
// · 진행자(host): roomAdmin + 발행/구독 (단계 3의 화면 승격 등 진행자 기능 토대)
// · 일반(participant): 발행/구독
// 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다.
async issueToken(
user: PublicUser,
code: string,
): Promise<MeetingTokenResult> {
if (!this.enabled) {
throw new BusinessException(
'SYS_001',
'화상회의 서비스가 아직 설정되지 않았습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
const { room, role } = await this.resolveRoomAccess(user.id, code);
const isHost = role === 'host';
const at = new AccessToken(this.apiKey, this.apiSecret, {
identity: user.id,
name: user.name,
// 장시간 회의 중 네트워크 재연결 시 토큰 만료로 재입장이 실패하지 않도록 넉넉히(12h).
ttl: '12h',
// role 은 진행자 식별용, avatarUrl 은 참여자/채팅 아바타 표시용
metadata: JSON.stringify({ role, avatarUrl: user.avatarUrl }),
});
at.addGrant({
roomJoin: true,
// LiveKit 방 식별자 = 코드(이름이 아니라 불변 코드라 이름을 바꿔도 같은 방으로 연결).
room: room.code,
canPublish: true,
canSubscribe: true,
canPublishData: true,
roomAdmin: isHost,
});
const token = await at.toJwt();
return { token, roomName: room.name, identity: user.id, role };
}
// 코드로 방을 찾아 입장 권한을 판정한다(자동 생성 없음 — 명시적으로 생성된 방만 입장 가능).
// · 방이 없으면 404. · 참여 제한(allow-list) 검사 후 생성자 일치로 역할을 판정.
private async resolveRoomAccess(
userId: string,
code: string,
): Promise<{ room: MeetingRoom; role: MeetingRole }> {
const room = await this.roomRepo.findOne({
where: { code },
relations: ['host'],
});
if (!room) {
throw new BusinessException(
'RES_001',
'회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.',
HttpStatus.NOT_FOUND,
);
}
const isHost = room.host?.id === userId;
if (
!isHost &&
this.isRestricted(room) &&
!room.allowedUserIds!.includes(userId)
) {
throw new BusinessException(
'AUTH_003',
'이 회의에 참여할 권한이 없습니다.',
HttpStatus.FORBIDDEN,
);
}
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 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,
allowedUserIds: dto.allowedUserIds?.length ? dto.allowedUserIds : null,
}),
);
const room = await this.roomRepo.findOne({
where: { id: saved.id },
relations: ['host'],
});
return this.toRoomResponse(room!, userId);
}
// 방 목록 — 전체 방(최근 생성순). 각 방의 접근 가능 여부/진행자 여부를 포함.
async listRooms(userId: string): Promise<MeetingRoomResponse[]> {
const rows = await this.roomRepo.find({
relations: ['host'],
order: { createdAt: 'DESC' },
});
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);
}
// 방 삭제 — 진행자(생성자)만 가능.
async deleteRoom(userId: string, roomId: string): Promise<void> {
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,
);
}
await this.roomRepo.remove(room);
}
private isRestricted(room: MeetingRoom): boolean {
return !!(room.allowedUserIds && room.allowedUserIds.length > 0);
}
private async toRoomResponse(
room: MeetingRoom,
userId: string,
): 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,12 +18,18 @@ 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'; // 프로젝트에서 제외됨
| 'member.removed' // 프로젝트에서 제외됨
| 'schedule.invited' // 일정 참석자로 지정됨
| 'schedule.updated' // 내가 참석한 일정이 수정됨
| 'schedule.removed'; // 내가 참석한 일정이 삭제됨
// 알림 — 사용자별 수신함. 표시 문구/아이콘은 프론트가 type+payload 로 조립한다.
@Entity('notifications')
@@ -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;
}
+132
View File
@@ -0,0 +1,132 @@
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;
// 문서의 AI 반영 상태 — 목록 배지 표기에 사용
// ready: 추출 성공(반영 가능) / failed: 지원 형식이나 추출 실패 / unsupported: 미지원 형식
export type AttachmentAiStatus = 'ready' | 'failed' | 'unsupported';
// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx·xlsx). 미지원/추출실패 배지 구분에 사용한다.
export function isExtractableType(mime: string, name: string): boolean {
const lower = name.toLowerCase();
return (
mime === 'text/plain' ||
mime === 'text/csv' ||
lower.endsWith('.txt') ||
lower.endsWith('.csv') ||
mime === 'application/pdf' ||
lower.endsWith('.pdf') ||
mime ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
lower.endsWith('.docx') ||
mime ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
lower.endsWith('.xlsx')
);
}
// 첨부 문서에서 평문 텍스트를 추출한다(업로드 시 1회). 추출 불가 형식/실패/빈 결과는 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) 평문 텍스트
if (
mime === 'text/plain' ||
mime === 'text/csv' ||
lower.endsWith('.txt') ||
lower.endsWith('.csv')
) {
return finalize(buf.toString('utf8'));
}
// 2) PDF — pdf-parse v2(PDFParse 클래스)
if (mime === 'application/pdf' || lower.endsWith('.pdf')) {
const parser = new PDFParse({ data: buf });
try {
const result = await parser.getText();
return finalize(result.text);
} finally {
await parser.destroy();
}
}
// 3) Word(.docx) — mammoth (버퍼 입력)
if (
mime ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
lower.endsWith('.docx')
) {
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;
}
}
// 공백·개행 정리 후 길이 상한 적용. 내용이 없으면 null.
function finalize(text: string): string | null {
const cleaned = text
.replace(/\r\n/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!cleaned) return null;
return cleaned.length > PER_DOC_TEXT_LIMIT
? cleaned.slice(0, PER_DOC_TEXT_LIMIT)
: cleaned;
}
@@ -19,9 +19,15 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { ProjectService, type ProjectResponse } from './project.service';
import {
ProjectService,
type ProjectResponse,
type ProjectDriveLinkResponse,
type ProjectDocumentResponse,
} from './project.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { LinkDriveFolderDto } from './dto/link-drive-folder.dto';
// 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요
@ApiTags('Project')
@@ -81,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(
@@ -91,4 +97,51 @@ export class ProjectController {
await this.projectService.remove(projectId, user.id);
return null;
}
/* ===================== 드라이브 연동 폴더 / AI 검토 문서 ===================== */
@Get(':projectId/drive-links')
@ApiOperation({ summary: '연동된 드라이브 폴더 목록 (멤버)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listDriveLinks(
@Param('projectId', ParseUUIDPipe) projectId: string,
): Promise<ProjectDriveLinkResponse[]> {
return this.projectService.listDriveLinks(projectId);
}
@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,
@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.unlinkDriveFolder(projectId, user.id, linkId);
return null;
}
@Get(':projectId/documents')
@ApiOperation({ summary: 'AI 검토 대상 문서 목록 (연동 폴더 내 파일)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listDocuments(
@Param('projectId', ParseUUIDPipe) projectId: string,
): Promise<ProjectDocumentResponse[]> {
return this.projectService.listDocuments(projectId);
}
}
+11 -1
View File
@@ -4,6 +4,9 @@ import { UserModule } from '../user/user.module';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.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';
@@ -15,7 +18,14 @@ import { ProjectMemberController } from './project-member.controller';
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
@Module({
imports: [
TypeOrmModule.forFeature([Project, ProjectMember, Task]),
TypeOrmModule.forFeature([
Project,
ProjectMember,
Task,
DriveFolder,
DriveFile,
ProjectFolderLink,
]),
UserModule,
ActivityModule,
NotificationModule,
+268 -2
View File
@@ -1,21 +1,59 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository, type FindOptionsOrder } from 'typeorm';
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 { 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 {
ProjectFolderLink,
type DriveLinkPermission,
} from '../drive/entities/project-folder-link.entity';
import {
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';
// 문서의 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;
aiStatus: DocumentAiStatus;
}
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
export interface ProjectResponse {
@@ -27,9 +65,11 @@ export interface ProjectResponse {
doneCount: number; // 완료 업무 수
totalCount: number; // 전체 업무 수
updatedAt: string;
// 단건(findOne)에서만 — 현재 사용자 권한
// 단건(findOne)에서만 — 현재 사용자 권한 + 연동 폴더/문서
canManage?: boolean; // admin
isOwner?: boolean; // 소유자
driveLinks?: ProjectDriveLinkResponse[];
documents?: ProjectDocumentResponse[];
}
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
@@ -42,6 +82,13 @@ export class ProjectService {
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@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,
) {}
@@ -94,6 +141,8 @@ export class ProjectService {
...base,
canManage: membership?.roleType === 'admin',
isOwner: membership?.isOwner ?? false,
driveLinks: await this.listDriveLinks(project.id),
documents: await this.listDocuments(project.id),
};
}
@@ -181,6 +230,223 @@ export class ProjectService {
}
}
// --- 드라이브 연동 폴더 / AI 검토 문서 ---
// 프로젝트에 연동된 드라이브 폴더 목록(최신순) — 프로젝트 상세에 포함
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;
}
// 드라이브 폴더를 프로젝트에 연동(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 files = await this.collectLinkedFiles(projectId, true);
const parts: string[] = [];
let used = 0;
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(`### ${f.name}\n${slice}`);
used += slice.length;
}
return parts.join('\n\n');
}
// 연동 폴더(자기+하위 트리) 내 활성 파일 수집. withText=true 면 추출 본문도 선택.
private async collectLinkedFiles(
projectId: string,
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();
}
// 프로젝트에 연동된 모든 폴더 + 그 하위 폴더 ID 집합(materialized path 기반)
private async collectLinkedFolderIds(projectId: string): Promise<string[]> {
const links = await this.linkRepo.find({
where: { project: { id: projectId } },
relations: { folder: true },
});
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);
}
return [...ids];
}
// 폴더(자기+하위) 내 활성 파일 수
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' },
});
}
// 드라이브 파일 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 {
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 toDriveLinkResponse(
link: ProjectFolderLink,
documentCount: number,
): ProjectDriveLinkResponse {
return {
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 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';
}
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
private async taskCountsByProject(
ids: string[],
@@ -0,0 +1,23 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
// 일정 카테고리 생성 DTO
export class CreateScheduleCategoryDto {
@ApiProperty({ description: '카테고리 이름', example: '회의' })
@IsString()
@IsNotEmpty({ message: '카테고리 이름을 입력해 주세요.' })
@MaxLength(40, { message: '카테고리 이름은 최대 40자입니다.' })
label!: string;
@ApiProperty({ description: '카테고리 색(hex)', example: '#1d4ed8' })
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/, {
message: '색상은 #RRGGBB 형식의 hex 값이어야 합니다.',
})
color!: string;
}
// 일정 카테고리 수정 DTO — 생성 DTO 의 부분 집합
export class UpdateScheduleCategoryDto extends PartialType(
CreateScheduleCategoryDto,
) {}
@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
// 일정 생성 DTO
export class CreateScheduleEventDto {
@ApiProperty({ description: '일정 제목', example: '주간 팀 스탠드업' })
@IsString()
@IsNotEmpty({ message: '일정 제목을 입력해 주세요.' })
@MaxLength(200, { message: '제목은 최대 200자입니다.' })
title!: string;
@ApiProperty({ description: '카테고리 id(uuid)' })
@IsUUID('4', { message: '올바른 카테고리를 선택해 주세요.' })
categoryId!: string;
@ApiProperty({ description: '시작일(YYYY-MM-DD)', example: '2026-06-22' })
@IsDateString({}, { message: '시작일 형식이 올바르지 않습니다.' })
start!: string;
@ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-06-22' })
@IsDateString({}, { message: '종료일 형식이 올바르지 않습니다.' })
end!: string;
@ApiPropertyOptional({
description: '표시용 시간 텍스트',
example: '09:30 10:00',
})
@IsOptional()
@IsString()
@MaxLength(100)
time?: string;
@ApiPropertyOptional({ description: '장소', example: '3층 포커스룸 A' })
@IsOptional()
@IsString()
@MaxLength(200)
place?: string;
@ApiPropertyOptional({ description: '설명/메모' })
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@ApiPropertyOptional({ description: '전 직원 대상 여부', example: false })
@IsOptional()
@IsBoolean()
allHands?: boolean;
@ApiPropertyOptional({ description: '참석자 사용자 id 목록', type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
@ArrayUnique()
attendeeIds?: string[];
}
// 일정 수정 DTO — 생성 DTO 의 부분 집합
export class UpdateScheduleEventDto extends PartialType(
CreateScheduleEventDto,
) {}
@@ -0,0 +1,29 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
// 일정 카테고리(타임라인 레인) — 전사 공유. 사용자가 추가·수정·삭제 가능.
// 음영색(weak/border)은 color 로부터 프론트가 color-mix 로 파생한다(저장하지 않음).
@Entity('schedule_categories')
export class ScheduleCategory {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 표시 이름(예: 회의, 휴가·부재)
@Column({ type: 'varchar' })
label!: string;
// 카테고리 색(hex, 예: #1d4ed8)
@Column({ type: 'varchar' })
color!: string;
// 표시 순서(레인/필터 정렬) — 작을수록 위
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,75 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { ScheduleCategory } from './schedule-category.entity';
// 일정(이벤트) — 전사 공유 캘린더의 단일 항목. 종일 기준, 다중일(start≠end) 지원.
@Entity('schedule_events')
// 주간 범위 조회(겹침 검색)에 사용되는 복합 인덱스
@Index(['startDate', 'endDate'])
export class ScheduleEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 카테고리(레인) — 카테고리 삭제 시 일정도 함께 삭제
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => ScheduleCategory, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'category_id' })
category!: Relation<ScheduleCategory>;
@Column({ type: 'varchar' })
title!: string;
// 시작·종료일(YYYY-MM-DD, 종일 기준). date 타입이라 문자열로 다룬다.
@Column({ name: 'start_date', type: 'date' })
startDate!: string;
@Column({ name: 'end_date', type: 'date' })
endDate!: string;
// 표시용 시간 텍스트(예: "09:30 10:00", "종일") — 프론트 모달이 조립
@Column({ type: 'varchar', default: '' })
time!: string;
@Column({ type: 'varchar', nullable: true })
place!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
// 전 직원 대상 여부 — true 면 개별 참석자 대신 "전 직원"으로 표시
@Column({ name: 'all_hands', type: 'boolean', default: false })
allHands!: boolean;
// 개별 참석자(전 직원이 아닐 때). 조인 테이블로 다대다 매핑.
@ManyToMany(() => User)
@JoinTable({
name: 'schedule_event_attendees',
joinColumn: { name: 'event_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
})
attendees!: User[];
// 작성자 — 사용자 삭제 시에도 일정은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'created_by' })
createdBy!: User | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,151 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
ScheduleService,
type ScheduleAttendee,
type ScheduleCategoryResponse,
type ScheduleEventResponse,
} from './schedule.service';
import {
CreateScheduleCategoryDto,
UpdateScheduleCategoryDto,
} from './dto/schedule-category.dto';
import {
CreateScheduleEventDto,
UpdateScheduleEventDto,
} from './dto/schedule-event.dto';
// YYYY-MM-DD 형식 검증(쿼리 파라미터용)
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// 일정(전사 공유 캘린더) 컨트롤러 — 인증 사용자면 누구나 조회·관리 가능.
@ApiTags('Schedule')
@Controller('schedule')
@UseGuards(JwtAuthGuard)
export class ScheduleController {
constructor(private readonly scheduleService: ScheduleService) {}
// ----- 카테고리 -----
@Get('categories')
@ApiOperation({ summary: '일정 카테고리 목록' })
@ApiResponse({ status: 200, description: '조회 성공' })
listCategories(): Promise<ScheduleCategoryResponse[]> {
return this.scheduleService.listCategories();
}
@Post('categories')
@ApiOperation({ summary: '일정 카테고리 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createCategory(
@Body() dto: CreateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
return this.scheduleService.createCategory(dto);
}
@Patch('categories/:id')
@ApiOperation({ summary: '일정 카테고리 수정' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateCategory(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
return this.scheduleService.updateCategory(id, dto);
}
@Delete('categories/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '일정 카테고리 삭제(포함 일정도 삭제)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteCategory(
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ success: boolean }> {
await this.scheduleService.deleteCategory(id);
return { success: true };
}
// ----- 참석자 선택 풀 -----
@Get('people')
@ApiOperation({ summary: '참석자 선택용 사용자 목록' })
@ApiResponse({ status: 200, description: '조회 성공' })
listPeople(): Promise<ScheduleAttendee[]> {
return this.scheduleService.listPeople();
}
// ----- 일정 -----
@Get('events')
@ApiOperation({ summary: '일정 목록(기간 겹침)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listEvents(
@Query('start') start: string,
@Query('end') end: string,
): Promise<ScheduleEventResponse[]> {
if (!start || !end || !DATE_RE.test(start) || !DATE_RE.test(end)) {
throw new BadRequestException(
'start, end 쿼리(YYYY-MM-DD)가 필요합니다.',
);
}
return this.scheduleService.listEvents(start, end);
}
@Post('events')
@ApiOperation({ summary: '일정 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createEvent(
@CurrentUser() user: PublicUser,
@Body() dto: CreateScheduleEventDto,
): Promise<ScheduleEventResponse> {
return this.scheduleService.createEvent(dto, {
id: user.id,
name: user.name,
});
}
@Patch('events/:id')
@ApiOperation({ summary: '일정 수정' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateEvent(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleEventDto,
): Promise<ScheduleEventResponse> {
return this.scheduleService.updateEvent(id, dto, {
id: user.id,
name: user.name,
});
}
@Delete('events/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '일정 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteEvent(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ success: boolean }> {
await this.scheduleService.deleteEvent(id, {
id: user.id,
name: user.name,
});
return { success: true };
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { NotificationModule } from '../notification/notification.module';
import { ScheduleCategory } from './entities/schedule-category.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import { ScheduleService } from './schedule.service';
import { ScheduleController } from './schedule.controller';
// 일정(전사 공유 캘린더) 모듈 — 카테고리/일정 CRUD. UserModule 로 참석자 풀 조회.
@Module({
imports: [
TypeOrmModule.forFeature([ScheduleCategory, ScheduleEvent]),
UserModule,
NotificationModule,
],
controllers: [ScheduleController],
providers: [ScheduleService],
exports: [ScheduleService],
})
export class ScheduleModule {}
@@ -0,0 +1,310 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { User } from '../user/entities/user.entity';
import { UserService } from '../user/user.service';
import { NotificationService } from '../notification/notification.service';
import type { NotificationType } from '../notification/entities/notification.entity';
import { ScheduleCategory } from './entities/schedule-category.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import { CreateScheduleCategoryDto } from './dto/schedule-category.dto';
import { CreateScheduleEventDto } from './dto/schedule-event.dto';
// 알림 행위자(누가 일정을 만들었/수정했/삭제했는지)
export interface ScheduleActor {
id: string;
name: string;
}
// 카테고리/일정 응답(원시) — 표시 음영색은 프론트가 color 로 파생
export interface ScheduleCategoryResponse {
id: string;
label: string;
color: string;
sortOrder: number;
}
export interface ScheduleAttendee {
id: string;
name: string;
avatarUrl: string | null;
}
export interface ScheduleEventResponse {
id: string;
categoryId: string;
title: string;
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
time: string;
place: string | null;
description: string | null;
allHands: boolean;
attendees: ScheduleAttendee[];
createdById: string | null;
}
// 일정(전사 공유 캘린더) 비즈니스 로직 — 카테고리/일정 CRUD + 참석자 매핑.
// (기본 카테고리 시드 없음 — 카테고리는 사용자가 직접 추가한다)
@Injectable()
export class ScheduleService {
constructor(
@InjectRepository(ScheduleCategory)
private readonly categoryRepo: Repository<ScheduleCategory>,
@InjectRepository(ScheduleEvent)
private readonly eventRepo: Repository<ScheduleEvent>,
private readonly userService: UserService,
private readonly notification: NotificationService,
) {}
// ----- 카테고리 -----
async listCategories(): Promise<ScheduleCategoryResponse[]> {
const rows = await this.categoryRepo.find({
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
return rows.map((c) => this.toCategoryResponse(c));
}
async createCategory(
dto: CreateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
// 다음 정렬값 = 현재 최대 + 1
const max = await this.categoryRepo
.createQueryBuilder('c')
.select('MAX(c.sort_order)', 'max')
.getRawOne<{ max: number | null }>();
const sortOrder = (max?.max ?? -1) + 1;
const saved = await this.categoryRepo.save(
this.categoryRepo.create({
label: dto.label.trim(),
color: dto.color,
sortOrder,
}),
);
return this.toCategoryResponse(saved);
}
async updateCategory(
id: string,
dto: Partial<CreateScheduleCategoryDto>,
): Promise<ScheduleCategoryResponse> {
const cat = await this.categoryRepo.findOne({ where: { id } });
if (!cat) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
if (dto.label !== undefined) cat.label = dto.label.trim();
if (dto.color !== undefined) cat.color = dto.color;
const saved = await this.categoryRepo.save(cat);
return this.toCategoryResponse(saved);
}
// 카테고리 삭제 — 포함된 일정도 FK CASCADE 로 함께 삭제된다.
async deleteCategory(id: string): Promise<void> {
const res = await this.categoryRepo.delete({ id });
if (!res.affected)
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
}
// ----- 일정 -----
// 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 전부.
async listEvents(
start: string,
end: string,
): Promise<ScheduleEventResponse[]> {
const rows = await this.eventRepo.find({
where: {
startDate: LessThanOrEqual(end),
endDate: MoreThanOrEqual(start),
},
relations: ['attendees', 'createdBy'],
order: { startDate: 'ASC', time: 'ASC' },
});
return rows.map((e) => this.toEventResponse(e));
}
async createEvent(
dto: CreateScheduleEventDto,
actor: ScheduleActor,
): Promise<ScheduleEventResponse> {
const category = await this.categoryRepo.findOne({
where: { id: dto.categoryId },
});
if (!category) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
const event = this.eventRepo.create({
category: { id: category.id } as ScheduleCategory,
title: dto.title.trim(),
startDate: dto.start,
endDate: dto.end,
time: dto.time?.trim() ?? '',
place: dto.place?.trim() || null,
description: dto.description?.trim() || null,
allHands: dto.allHands ?? false,
attendees: this.attendeeRefs(dto.allHands, dto.attendeeIds),
createdBy: { id: actor.id } as User,
});
const saved = await this.eventRepo.save(event);
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
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);
}
async updateEvent(
id: string,
dto: Partial<CreateScheduleEventDto>,
actor: ScheduleActor,
): Promise<ScheduleEventResponse> {
const event = await this.eventRepo.findOne({
where: { id },
relations: ['attendees'],
});
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({
where: { id: dto.categoryId },
});
if (!category)
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
event.category = { id: category.id } as ScheduleCategory;
}
if (dto.title !== undefined) event.title = dto.title.trim();
if (dto.start !== undefined) event.startDate = dto.start;
if (dto.end !== undefined) event.endDate = dto.end;
if (dto.time !== undefined) event.time = dto.time.trim();
if (dto.place !== undefined) event.place = dto.place.trim() || null;
if (dto.description !== undefined)
event.description = dto.description.trim() || null;
if (dto.allHands !== undefined) event.allHands = dto.allHands;
// 참석자/전직원 변경이 있으면 재매핑
if (dto.allHands !== undefined || dto.attendeeIds !== undefined) {
const allHands = dto.allHands ?? event.allHands;
event.attendees = this.attendeeRefs(allHands, dto.attendeeIds);
}
await this.eventRepo.save(event);
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로 신규→초대, 기존→수정됨.
// (전 직원 일정이면 전 사용자가 실효 참석자 — 수정 전/후 어느 쪽이든 반영)
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);
return this.findEventOrThrow(id);
}
async deleteEvent(id: string, actor: ScheduleActor): Promise<void> {
// 삭제 전 참석자(알림 대상)를 먼저 확보
const event = await this.eventRepo.findOne({
where: { id },
relations: ['attendees'],
});
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
// 전 직원 일정이면 전 사용자에게 삭제 알림
const recipients = event.allHands
? await this.userService.findAllIds()
: (event.attendees ?? []).map((a) => a.id);
await this.eventRepo.delete({ id });
await this.notifyEvent('schedule.removed', recipients, actor, {
eventTitle: event.title,
start: event.startDate,
});
}
// 참석자 선택 풀(전사 사용자) — 일정 작성 모달의 참석자 선택용.
async listPeople(): Promise<ScheduleAttendee[]> {
const users = await this.userService.search('', 500);
return users.map((u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatarUrl ?? null,
}));
}
// ----- 내부 헬퍼 -----
// 전 직원 일정이면 개별 참석자는 비운다. 아니면 id 참조 배열로 매핑.
private attendeeRefs(allHands?: boolean, ids?: string[]): User[] {
if (allHands) return [];
return [...new Set(ids ?? [])].map((id) => ({ id }) as User);
}
// 중복 제거된 id 배열
private idsOf(ids?: string[]): string[] {
return [...new Set(ids ?? [])];
}
// 참석자 알림 발송(행위자=본인은 NotificationService 가 자동 제외). 빈 수신자면 생략.
private async notifyEvent(
type: NotificationType,
recipientIds: string[],
actor: ScheduleActor,
payload: Record<string, unknown>,
): Promise<void> {
if (!recipientIds.length) return;
await this.notification.notify({
recipientIds,
actorId: actor.id,
type,
payload: { actorName: actor.name, ...payload },
});
}
private async findEventOrThrow(id: string): Promise<ScheduleEventResponse> {
const e = await this.eventRepo.findOne({
where: { id },
relations: ['attendees', 'createdBy'],
});
if (!e) throw new NotFoundException('일정을 찾을 수 없습니다.');
return this.toEventResponse(e);
}
private toCategoryResponse(c: ScheduleCategory): ScheduleCategoryResponse {
return {
id: c.id,
label: c.label,
color: c.color,
sortOrder: c.sortOrder,
};
}
private toEventResponse(e: ScheduleEvent): ScheduleEventResponse {
return {
id: e.id,
categoryId: e.category?.id ?? '',
title: e.title,
start: e.startDate,
end: e.endDate,
time: e.time ?? '',
place: e.place ?? null,
description: e.description ?? null,
allHands: e.allHands,
attendees: (e.attendees ?? []).map((u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatarUrl ?? null,
})),
createdById: e.createdBy?.id ?? null,
};
}
}
@@ -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
+13
View File
@@ -10,6 +10,11 @@ services:
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
# 화상회의 — 브라우저가 접속할 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
@@ -36,6 +41,14 @@ services:
networks: !override
- app-network
# 화상회의는 개발에서 LiveKit Cloud 를 사용한다(로컬 SFU 컨테이너 없음).
# WSL2/Docker Desktop 의 WebRTC 네트워킹 제약을 우회하기 위함.
# base 의 livekit 은 운영 기본 서비스라, dev 에서는 selfhost-livekit 프로파일로 가두어
# 기동되지 않게 한다(이 프로파일은 dev 에서 켜지 않으므로 컨테이너 미생성).
livekit:
profiles:
- selfhost-livekit
db:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
+51
View File
@@ -11,6 +11,10 @@ services:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
- 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:
@@ -62,6 +66,17 @@ services:
- REDIS_PASSWORD=${REDIS_PASSWORD}
# AI 요청 타임아웃(ms) — 프론트(VITE_AI_TIMEOUT_MS)와 동일 값을 루트 .env 에서 공급
- AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
# 화상회의(LiveKit) — 토큰 발급에 키/시크릿, 서버 API 호출에 내부 URL 사용
- 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
@@ -144,6 +159,42 @@ services:
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ----------------------------------------
# 5. LiveKit (화상회의 SFU) — 셀프호스팅 (운영 기본 서비스, 소스 직접 빌드)
# SFU 코어(pkg/sfu) 커스터마이즈를 위해 공식 이미지 대신 livekit-server/ 소스를
# 직접 빌드한다(git subtree, v1.8.4 기준). 소스·수정은 이 레포에서 관리한다.
# 운영(base)에서는 db·redis 처럼 항상 함께 기동된다.
# 개발(dev)에서는 docker-compose.dev.yml 이 selfhost-livekit 프로파일로 가두어
# 생성되지 않는다(개발은 LiveKit Cloud 사용).
# 배포·클러스터링 가이드: livekit/DEPLOY.md
# ----------------------------------------
livekit:
build:
context: ./livekit-server
dockerfile: Dockerfile
container_name: ${PROJECT_NAME}-livekit
command: --config /etc/livekit/livekit.yaml
environment:
# 키/시크릿은 "key: secret" 형식으로 주입(콜론+공백 때문에 전체를 따옴표로 감쌈)
- "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
expose:
- "7880" # 시그널링/HTTP API — 리버스 프록시(TLS)에서 wss 로 라우팅
ports:
- "7881:7881" # ICE/TCP 폴백
- "7882:7882/udp" # ICE/UDP (mux)
volumes:
# 클러스터링 시에는 redis 비밀번호가 담긴 사본을 마운트(DEPLOY.md 참고)
- ./livekit/livekit.prod.yaml:/etc/livekit/livekit.yaml:ro
restart: unless-stopped
networks:
- app-network
- proxy
deploy:
resources:
limits:
cpus: '2.0'
memory: 2g
networks:
app-network:
driver: bridge
+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
# 따라서 이 파일에 별도로 작성할 값은 없다.
# ==========================================
+4
View File
@@ -14,6 +14,10 @@ ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
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}
+4
View File
@@ -5,6 +5,10 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
/** AI 요청 타임아웃(ms) — 백엔드 AI_TIMEOUT_MS 와 동일 값 */
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 {
+2 -1
View File
@@ -9,7 +9,8 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# 화상회의(LiveKit)용 카메라/마이크는 자기 출처(self)에 허용. 위치정보는 차단 유지.
add_header Permissions-Policy "geolocation=(), microphone=(self), camera=(self)" always;
# ----------------------------------------
# 압축 (Gzip)
+143 -60
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.14.0",
"livekit-client": "^2.19.2",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
@@ -556,6 +557,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -720,12 +727,13 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1074,6 +1082,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.45.8",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -1275,9 +1298,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1295,9 +1315,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1315,9 +1332,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1335,9 +1349,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1355,9 +1366,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1375,9 +1383,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1395,9 +1400,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1415,9 +1417,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1606,9 +1605,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1626,9 +1622,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1646,9 +1639,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1666,9 +1656,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1686,9 +1673,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1706,9 +1690,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1754,6 +1735,17 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
@@ -1838,6 +1830,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3747,6 +3746,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -4347,6 +4355,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
@@ -4717,9 +4734,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4741,9 +4755,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4765,9 +4776,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4789,9 +4797,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4847,6 +4852,26 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/livekit-client": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.45.8",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.5"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4877,6 +4902,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5690,6 +5728,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -5703,6 +5751,21 @@
"node": ">=v12.22.7"
}
},
"node_modules/sdp": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -6136,9 +6199,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -6166,6 +6227,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -6833,6 +6903,19 @@
"node": ">=20"
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"axios": "^1.14.0",
"livekit-client": "^2.19.2",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
+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)
})(),
)
})
+118
View File
@@ -52,6 +52,21 @@
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.05);
--shadow-md: 0 4px 16px rgba(20, 24, 33, 0.1), 0 1px 3px rgba(20, 24, 33, 0.06);
--shadow-pop: 0 8px 28px rgba(20, 24, 33, 0.16), 0 2px 6px rgba(20, 24, 33, 0.08);
/* 다크 캔버스(화상회의 회의실/프리조인 전용) */
--d-bg: #0e1014;
--d-panel: #16191f;
--d-panel-2: #1b1f27;
--d-border: #2a2f39;
--d-text: #eceef2;
--d-text-2: #aab2bf;
--d-text-3: #717a88;
--tile: #1b1d23;
/* 다크 위 상태색(라이트 토큰을 다크에 쓰면 탁해져 밝은 변형을 별도 정의) */
--d-red: #ff7a8a;
--d-green: #46d18a;
--d-amber: #f0c478;
--d-accent-soft: #c7c2ff;
}
/* ============================================================
@@ -569,6 +584,95 @@ body {
color: var(--text-3);
}
/* ============================================================
* 폼 필드 (폼 모달 공통 — BaseModal 본문 등에서 사용)
* ============================================================ */
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
}
/* 필수 입력 표시(*) — 업무/프로젝트 작성 폼과 동일 */
.form-label .req {
color: var(--accent);
font-weight: 700;
}
.form-row {
display: flex;
gap: 0.75rem;
}
.form-row .form-field {
flex: 1;
min-width: 0;
}
.form-input {
width: 100%;
height: 2.5rem;
padding: 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
}
.form-input::placeholder {
color: var(--text-3);
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.form-textarea {
height: auto;
min-height: 5.75rem;
padding: 0.625rem 0.75rem;
line-height: 1.5;
resize: vertical;
}
/* 네이티브 select 래퍼 + 셰브론 */
.form-select-wrap {
position: relative;
}
.form-select {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 2.5rem;
padding: 0 2.125rem 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.form-select-chev {
position: absolute;
right: 0.625rem;
top: 50%;
transform: translateY(-50%);
display: grid;
color: var(--text-3);
pointer-events: none;
}
.form-select-chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* ============================================================
* 더보기 버튼 (목록 페이지네이션 공통)
* ============================================================ */
@@ -593,3 +697,17 @@ body {
opacity: 0.6;
cursor: default;
}
/* ============================================================
* 빈/상태 메시지 박스 (목록 로딩·빈 상태 공통)
* ============================================================ */
.empty-state {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 2.5rem 1.25rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
}
+10 -57
View File
@@ -1,7 +1,8 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
interface Option {
value: T
@@ -23,75 +24,25 @@ const props = withDefaults(
)
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: props.align,
matchWidth: true,
})
// 현재 선택된 옵션 라벨(트리거에 표시)
const selectedLabel = computed(
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
)
// 트리거 위치 기준으로 메뉴 좌표 계산(뷰포트 fixed)
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = {
top: `${r.bottom + 6}px`,
minWidth: `${r.width}px`,
}
if (props.align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function select(value: T): void {
emit('update:modelValue', value)
open.value = false
close()
}
// 바깥 클릭(트리거/메뉴 외부) / Esc 로 닫기
function onDocClick(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (root.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onKeydown(e: KeyboardEvent): void {
if (open.value && e.key === 'Escape') open.value = false
}
// 스크롤/리사이즈 시 위치가 어긋나므로 닫는다(간단·안전)
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onKeydown)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
<template>
<div
ref="root"
class="dd"
:class="{ push: pushRight, block }"
>
@@ -175,6 +126,8 @@ onBeforeUnmount(() => {
}
.dd.block .dd-label {
flex: 1;
/* button 기본 text-align:center 상속으로 가운데 보이는 것을 폼 셀렉트답게 좌측 정렬 */
text-align: left;
}
/* 트리거 — 기존 정렬/필터 pill 과 동일한 외형 */
+22 -54
View File
@@ -1,60 +1,28 @@
<script setup lang="ts">
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
import {
WORK_STATUS_META as META,
WORK_STATUS_ORDER as ORDER,
} from '@/shared/constants/workStatus'
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
const props = defineProps<{ modelValue: ChecklistWorkStatus }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: ChecklistWorkStatus): void }>()
const META: Record<ChecklistWorkStatus, { label: string; color: string; weak: string }> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
const ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
const cur = computed(() => META[props.modelValue])
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅(우측 정렬, 너비는 CSS 고정)
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: 'right',
gap: 5,
})
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
menuStyle.value = {
top: `${r.bottom + 5}px`,
right: `${Math.max(0, window.innerWidth - r.right)}px`,
}
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function pick(v: ChecklistWorkStatus): void {
emit('update:modelValue', v)
open.value = false
close()
}
function onDoc(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (triggerRef.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDoc)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDoc)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
<template>
@@ -122,16 +90,16 @@ onBeforeUnmount(() => {
.ss-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 30px;
min-width: 100px;
padding: 0 9px 0 11px;
gap: 6px;
height: 26px;
min-width: 86px;
padding: 0 8px 0 9px;
border: 1px solid var(--border-strong);
border-radius: 8px;
border-radius: 7px;
background: var(--w);
color: var(--c);
font-family: inherit;
font-size: 0.781rem;
font-size: 0.719rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
@@ -140,8 +108,8 @@ onBeforeUnmount(() => {
border-color: var(--c);
}
.ss-dot {
width: 8px;
height: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c);
flex-shrink: 0;
@@ -151,8 +119,8 @@ onBeforeUnmount(() => {
text-align: left;
}
.ss-chev {
width: 14px;
height: 14px;
width: 13px;
height: 13px;
}
.ss-menu {
position: fixed;
@@ -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>
@@ -0,0 +1,77 @@
<script setup lang="ts">
// 프로젝트 문서의 AI 추천 반영 상태 배지
// ready: 반영됨 / pending: 추출 대기 / failed: 추출 실패 / unsupported: 미지원 형식
import { computed } from 'vue'
interface Props {
status: 'ready' | 'pending' | 'failed' | 'unsupported'
}
const props = defineProps<Props>()
// 상태별 라벨·툴팁·스타일 클래스
const META: Record<Props['status'], { label: string; title: string; cls: string }> = {
ready: {
label: 'AI 반영',
title: 'AI 업무 추천에 이 문서 내용이 반영됩니다.',
cls: 'ok',
},
pending: {
label: '추출 대기',
title: '다음 AI 업무 추천 시 문서 내용을 추출해 반영합니다.',
cls: 'muted',
},
failed: {
label: '추출 실패',
title: '문서에서 텍스트를 추출하지 못해 AI 추천에 반영되지 않습니다.',
cls: 'warn',
},
unsupported: {
label: 'AI 반영 불가',
title: '미지원 형식이라 AI 추천에 반영되지 않습니다. (txt·csv·pdf·docx 지원)',
cls: 'muted',
},
}
const meta = computed(() => META[props.status])
</script>
<template>
<span
class="ai-badge"
:class="meta.cls"
:title="meta.title"
>
{{ meta.label }}
</span>
</template>
<style scoped>
.ai-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
height: 1.25rem;
padding: 0 0.4375rem;
border-radius: 0.3125rem;
border: 1px solid transparent;
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
cursor: default;
}
.ai-badge.ok {
color: var(--green);
background: var(--green-weak);
border-color: var(--green-border);
}
.ai-badge.warn {
color: var(--amber);
background: var(--amber-weak);
border-color: var(--amber-border);
}
.ai-badge.muted {
color: var(--text-3);
background: #f3f4f6;
border-color: var(--border);
}
</style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
// 아바타 스택 + 오버플로(+N) + 클릭 팝오버 목록 공용 컴포넌트.
// 좁은 영역에서 다수의 사람(참석자/담당자 등)을 컴팩트하게 표시하고,
// 클릭하면 전체 목록(아바타 + 이름 + 선택적 보조문구 sub)을 펼친다.
import { computed, ref } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useClickOutside } from '@/composables/useClickOutside'
interface StackPerson {
id: string
name: string
avatarUrl?: string | null
// 팝오버 목록의 이름 아래 보조문구(예: 역할). 없으면 표시 안 함
sub?: string | null
}
const props = withDefaults(
defineProps<{ people: StackPerson[]; max?: number }>(),
{ max: 6 },
)
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
// max 초과 시 (max-1)개만 보이고 나머지는 +N
const shown = computed(() =>
props.people.length > props.max
? props.people.slice(0, props.max - 1)
: props.people,
)
const extra = computed(() =>
props.people.length > props.max ? props.people.length - (props.max - 1) : 0,
)
</script>
<template>
<div
ref="rootRef"
class="as"
>
<button
type="button"
class="as-trigger"
:class="{ open }"
@click="open = !open"
>
<span class="as-stack">
<span
v-for="p in shown"
:key="p.id"
class="as-av"
><UserAvatar
:url="p.avatarUrl"
:name="p.name"
/></span>
<span
v-if="extra > 0"
class="as-more"
>+{{ extra }}</span>
</span>
<span class="as-chev">
<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>
</button>
<div
v-if="open"
class="as-panel"
>
<div
v-for="p in people"
:key="p.id"
class="as-prow"
>
<span class="as-av sm"><UserAvatar
:url="p.avatarUrl"
:name="p.name"
/></span>
<span class="as-pinfo">
<span class="as-pnm">{{ p.name }}</span>
<span
v-if="p.sub"
class="as-psub"
>{{ p.sub }}</span>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.as {
position: relative;
display: inline-block;
}
.as-trigger {
display: inline-flex;
align-items: center;
gap: 0.5625rem;
border: none;
background: none;
cursor: pointer;
padding: 0.3125rem 0.5rem 0.3125rem 0.3125rem;
margin: -0.3125rem -0.5rem -0.3125rem -0.3125rem;
border-radius: 0.5625rem;
}
.as-trigger:hover,
.as-trigger.open {
background: #f1f2f4;
}
.as-stack {
display: inline-flex;
align-items: center;
}
.as-av {
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
flex-shrink: 0;
}
.as-stack .as-av,
.as-stack .as-more {
margin-left: -0.5625rem;
box-shadow: 0 0 0 2px var(--panel);
}
.as-stack .as-av:first-child {
margin-left: 0;
}
.as-more {
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
background: #eceef1;
color: var(--text-2);
flex-shrink: 0;
}
.as-chev {
color: var(--text-3);
display: grid;
transition: transform 0.15s ease;
}
.as-chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
.as-trigger.open .as-chev {
transform: rotate(180deg);
}
.as-panel {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
min-width: 12.5rem;
max-width: 16.25rem;
max-height: 15rem;
overflow-y: auto;
z-index: 20;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-pop);
padding: 0.3125rem;
}
.as-prow {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
white-space: nowrap;
}
.as-prow:hover {
background: #f4f5f7;
}
.as-av.sm {
width: 1.5rem;
height: 1.5rem;
}
.as-pinfo {
display: flex;
flex-direction: column;
min-width: 0;
}
.as-pnm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
}
.as-psub {
font-size: 0.719rem;
color: var(--text-3);
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 공용 폼 모달 셸 — backdrop + 헤더(제목/닫기) + 본문 슬롯 + 푸터 슬롯.
// Teleport(body) · ESC · 바깥(backdrop) 클릭 닫기 내장.
// 전역 .modal*(확인 다이얼로그 전용)과 충돌하지 않도록 bm- 접두 클래스를 쓴다.
// 폼 필드는 전역 .form-* 클래스(relay.css)를 본문 슬롯에서 사용한다.
import { onBeforeUnmount, onMounted } from 'vue'
withDefaults(
defineProps<{
title: string
// 너비: md(기본 33.75rem) | sm(27.5rem)
size?: 'md' | 'sm'
}>(),
{ size: 'md' },
)
const emit = defineEmits<{ (e: 'close'): void }>()
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div
class="bm-back"
@mousedown.self="emit('close')"
>
<div
class="bm"
:class="{ 'bm-sm': size === 'sm' }"
role="dialog"
aria-modal="true"
>
<div class="bm-head">
<div class="bm-head-text">
<h3>{{ title }}</h3>
<div
v-if="$slots.subtitle"
class="bm-sub"
>
<slot name="subtitle" />
</div>
</div>
<button
type="button"
class="bm-x"
aria-label="닫기"
@click="emit('close')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="bm-body">
<slot />
</div>
<div
v-if="$slots.foot"
class="bm-foot"
>
<slot name="foot" />
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.bm-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.bm {
width: 100%;
max-width: 33.75rem;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
display: flex;
flex-direction: column;
}
.bm.bm-sm {
max-width: 27.5rem;
}
.bm-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 1.375rem 1.5rem 1rem;
position: sticky;
top: 0;
background: #fff;
border-radius: 0.875rem 0.875rem 0 0;
z-index: 1;
}
.bm-head h3 {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.bm-sub {
font-size: 0.8125rem;
color: var(--text-2);
margin-top: 0.25rem;
}
.bm-sub :deep(b) {
color: var(--text-2);
font-weight: 600;
}
.bm-x {
flex-shrink: 0;
}
.bm-x {
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
display: grid;
place-items: center;
margin-right: -0.3125rem;
}
.bm-x:hover {
background: #f1f2f4;
color: var(--text);
}
.bm-x svg {
width: 1.125rem;
height: 1.125rem;
}
.bm-body {
padding: 0.5rem 1.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 1.125rem;
text-align: left;
}
.bm-foot {
display: flex;
justify-content: flex-end;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
position: sticky;
bottom: 0;
border-radius: 0 0 0.875rem 0.875rem;
}
/* 푸터 버튼 — 전역 .mbtn(확인 다이얼로그용 flex:1) 대신 폼 모달용 우측정렬 컴팩트 */
.bm-foot :deep(.mbtn) {
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.bm-foot :deep(.mbtn svg) {
width: 0.9375rem;
height: 0.9375rem;
}
.bm-foot :deep(.mbtn:disabled) {
opacity: 0.45;
cursor: not-allowed;
}
.bm-foot :deep(.mbtn.primary:disabled),
.bm-foot :deep(.mbtn.primary:disabled:hover) {
background: var(--accent);
border-color: var(--accent);
}
</style>
@@ -0,0 +1,10 @@
<script setup lang="ts">
// 빈/상태 메시지 박스 공용 컴포넌트 — 목록 로딩·빈 상태 등에 사용.
// 전역 .empty-state 카드 스타일을 쓰고, 메시지는 기본 슬롯으로 전달한다.
</script>
<template>
<div class="empty-state">
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
// 목록 페이지네이션 '더보기' 버튼 공용 컴포넌트 — 전역 .load-more 스타일 사용.
// 부모는 보통 v-if="hasMore" 로 노출하고, @load 에서 다음 페이지를 불러온다.
defineProps<{ loading?: boolean }>()
const emit = defineEmits<{ (e: 'load'): void }>()
</script>
<template>
<button
class="load-more"
type="button"
:disabled="loading"
@click="emit('load')"
>
{{ loading ? '불러오는 중…' : '더보기' }}
</button>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts" generic="T extends string">
// 세그먼트 토글(필터/탭) 공용 컴포넌트 — 전역 .segmented 스타일을 그대로 사용.
// options 의 count 가 있으면 라벨 뒤에 수량(.n)을 표기한다.
interface SegOption {
value: T
label: string
count?: number
}
defineProps<{ modelValue: T; options: SegOption[] }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
</script>
<template>
<div class="segmented">
<button
v-for="o in options"
:key="o.value"
type="button"
:class="{ active: o.value === modelValue }"
@click="emit('update:modelValue', o.value)"
>
{{ o.label }}<span
v-if="o.count !== undefined"
class="n"
>{{ o.count }}</span>
</button>
</div>
</template>
@@ -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>
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 회의실 채팅 — 데이터 채널 메시지 목록 + 입력. 새 메시지 도착 시 맨 아래로 스크롤.
import { nextTick, ref, watch } from 'vue'
import MeetingIcon from './MeetingIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { MAX_CHAT_LENGTH } from '@/composables/useLiveKitRoom'
import type { ChatMessage } from '@/types/meeting'
interface Props {
messages: ChatMessage[]
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'send', text: string): void }>()
const text = ref('')
const listRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null)
// 입력 높이 자동 확장 — 최대 약 7.5rem까지는 스크롤바 없이 늘어나고, 초과 시에만 스크롤.
const MAX_INPUT_HEIGHT = 120
function autoGrow() {
const el = inputRef.value
if (!el) return
el.style.height = 'auto'
const full = el.scrollHeight
el.style.height = `${Math.min(full, MAX_INPUT_HEIGHT)}px`
el.style.overflowY = full > MAX_INPUT_HEIGHT ? 'auto' : 'hidden'
}
// 시각(HH:MM) 표시
function fmtTime(ts: number): string {
const d = new Date(ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function submit() {
const body = text.value.trim()
if (!body) return
emit('send', body)
text.value = ''
void nextTick(autoGrow) // 전송 후 높이 초기화
}
// 메시지 증가 시 맨 아래로
watch(
() => props.messages.length,
async () => {
await nextTick()
const el = listRef.value
if (el) el.scrollTop = el.scrollHeight
},
)
</script>
<template>
<div class="chat">
<div
ref="listRef"
class="chat-list"
>
<div
v-if="messages.length === 0"
class="chat-empty"
>
아직 메시지가 없습니다. 메시지를 보내보세요.
</div>
<div
v-for="m in messages"
:key="m.id"
class="chat-msg"
:class="{ self: m.self }"
>
<span
v-if="!m.self"
class="chat-av"
><UserAvatar
:url="m.senderAvatarUrl"
:name="m.senderName"
/></span>
<div class="chat-col">
<div class="chat-meta">
<span
v-if="!m.self"
class="chat-name"
>{{ m.senderName }}</span>
<span class="chat-time">{{ fmtTime(m.ts) }}</span>
</div>
<div class="chat-bubble">
{{ m.text }}
</div>
</div>
</div>
</div>
<div class="chat-input">
<textarea
ref="inputRef"
v-model="text"
rows="1"
:maxlength="MAX_CHAT_LENGTH"
placeholder="메시지 보내기… (Shift+Enter 줄바꿈)"
aria-label="채팅 메시지"
@input="autoGrow"
@keydown.enter.exact.prevent="submit"
/>
<button
type="button"
class="chat-send"
:disabled="!text.trim()"
title="보내기"
@click="submit"
>
<MeetingIcon name="send" />
</button>
</div>
</div>
</template>
<style scoped>
.chat {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.chat-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1rem 0.875rem;
scrollbar-width: thin;
scrollbar-color: var(--d-border) transparent;
}
.chat-list::-webkit-scrollbar {
width: 0.4375rem;
}
.chat-list::-webkit-scrollbar-thumb {
background: var(--d-border);
border-radius: 0.25rem;
}
.chat-empty {
margin: auto;
text-align: center;
font-size: 0.8125rem;
color: var(--d-text-3);
}
.chat-msg {
display: flex;
gap: 0.5625rem;
align-items: flex-start;
}
.chat-msg.self {
flex-direction: row-reverse;
}
.chat-av {
width: 1.875rem;
height: 1.875rem;
flex-shrink: 0;
}
.chat-col {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-width: 80%;
min-width: 0;
}
.chat-msg.self .chat-col {
align-items: flex-end;
}
.chat-meta {
display: flex;
align-items: baseline;
gap: 0.4375rem;
}
.chat-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--d-text-2);
}
.chat-time {
font-size: 0.65625rem;
color: var(--d-text-3);
}
.chat-bubble {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--d-text);
background: var(--d-panel-2);
border: 1px solid var(--d-border);
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border-top-left-radius: 0.25rem;
word-break: break-word;
white-space: pre-wrap;
}
.chat-msg.self .chat-bubble {
background: var(--accent);
border-color: var(--accent);
color: #fff;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.25rem;
}
.chat-input {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--d-border);
}
.chat-input textarea {
flex: 1;
min-width: 0;
min-height: 2.5rem;
max-height: 7.5rem;
padding: 0.5625rem 0.8125rem;
border: 1px solid var(--d-border);
border-radius: 0.625rem;
background: var(--d-panel-2);
color: var(--d-text);
font-family: inherit;
font-size: 0.8125rem;
line-height: 1.4;
outline: none;
resize: none;
overflow-y: hidden;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input textarea::placeholder {
color: var(--d-text-3);
}
.chat-send {
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
border: none;
border-radius: 0.625rem;
background: var(--accent);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.chat-send svg {
width: 1.125rem;
height: 1.125rem;
}
.chat-send:hover:not(:disabled) {
background: var(--accent-hover);
}
.chat-send:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>

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