# Relay 백엔드 연동 진행 현황

> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
> 최종 갱신: 2026-06-19 · 현재 위치: **0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 + 실시간 알림 + Redis 캐싱 완료.**
> 8단계 진행: **① 페이지네이션 전 도메인 완료**(더보기/전체 로드). **② 실시간 알림(인앱 + SSE) 완료**. **③ Redis 캐싱 완료** — CacheModule을 Redis(keyv)로 전환(미설정 시 인메모리 폴백), 저장소 목록(`GET /repos`)을 버전 네임스페이스 캐시. 그 외 8단계 항목(소셜 로그인·이메일 인증·AI 패널)은 미착수.

---

## 1. 작업 전략

- **도메인별 수직 슬라이스**: 한 도메인을 `엔티티 → DTO → service → controller → Swagger → 프론트 composable → store → 페이지 목업 교체`까지 끝내고 다음 도메인으로 이동. (수평 분할 아님)
- 프론트는 백엔드 원시 데이터(타임스탬프/카운트)를 받아 **표시용 파생값(상대시간·진행률·아바타 색상 등)은 `shared/utils/format.ts`에서 계산**.
- 모든 응답은 표준 래퍼 `{ success, data }` / `{ success, error }`. 에러 코드 맵은 `CLAUDE.md` SSOT.
- 인증은 **이메일+JWT만 먼저**(소셜 로그인·이메일 인증은 8단계로 보류). access/refresh 모두 HttpOnly·Secure 쿠키.

### 단계 순서

```
0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드+프론트)
→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 ✅(백엔드+프론트) → 6. 활동 피드 ✅(백엔드+프론트) → 7. 내 업무 → 8. 마무리
```

---

## 2. 완료된 단계

### 0단계 — 계약 정렬 ✅

- **`docs/api-contract.md`**: 7개 도메인 전체 엔드포인트(요청/응답/권한), ID 전략, 파생 필드 규칙, 상태 머신, 비즈니스 에러 메커니즘을 단일 진실 공급원으로 정의.
- **비즈니스 에러 메커니즘**: `backend/src/common/exceptions/business.exception.ts` 의 `BusinessException(code, message, status)`.
  - 전역 필터(`http-exception.filter.ts`)가 `{ code, message }` 를 status 매핑보다 **우선** 처리 → 정확한 HTTP 상태를 유지하면서 사용자에게 구체 문구 노출.
  - 프론트 `useApi` 인터셉터는 `BIZ_001` 코드일 때 서버 `message` 를 우선 표시.
  - 기존 status→코드 매핑 구조는 그대로 유지(덧댄 우선 분기). `BusinessException` 을 쓰지 않으면 기존과 동일 동작.

### 1단계 — 인증 (이메일+JWT) ✅

**백엔드** `backend/src/modules/auth/`, `modules/user/`
- `User` 엔티티(UUID, email unique, `password_hash` select:false, name, role nullable).
- `UserService`(Repository 기반): `findById`/`findByEmail`/`findByEmailWithPassword`/`create`/`toPublic`.
- `AuthService`: 가입(중복 검사), 로그인 검증(bcryptjs), access/refresh 토큰 발급.
- `AuthController`: `POST /auth/signup`·`/login`·`/logout`·`/refresh`(refresh 가드), `GET /auth/me`(access 가드). HttpOnly 쿠키 발급/제거.
- JWT access/refresh 전략(쿠키에서 추출) + 가드 + `@CurrentUser()` 데코레이터.
- `main.ts` 에 `cookie-parser`. JWT 시크릿은 `backend/.env.{env}`(커밋된 `.example` 포함).

**프론트** `frontend/src/`
- `types/auth.ts`, `composables/useAuth.ts`, `stores/auth.store.ts`(bootstrap/login/signup/logout).
- `composables/useApi.ts`: 401→`/auth/refresh` 자동 1회 재시도, `silent` 플래그(부트스트랩 무알림), BIZ_001 서버 메시지 우선.
- 라우터 가드: 최초 진입 시 `/auth/me` 세션 복원 + 미인증 리다이렉트(redirect 쿼리 보존).
- LoginPage/SignupPage 실제 연동(소셜 버튼은 안내, 가입 즉시 자동 로그인), AppShell 아바타=실사용자 + 로그아웃 드롭다운.

**로그인 실패 문구**: `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)` / 중복 가입: 동일 패턴 409.

### 2단계 — 저장소(Repo) CRUD ✅

**백엔드** `backend/src/modules/repo/`
- `Repo` 엔티티: `slugName`(영문, 라우팅 id, unique), `owner`('marketing-team' 고정 상수), `name`(한글 표시제목), `description`, `visibility`, `branch`.
- `RepoMember` 엔티티: repo↔user 조인, `roleType`(admin/member), `isOwner`, `@Unique([repo, user])`.
- `RepoService`: 내 멤버십 저장소 목록 / 단건(멤버·공개 접근 체크) / 생성(생성자를 owner·admin 등록) / 수정(admin) / 삭제(owner).
- `GET/POST/PATCH/DELETE /repos[/:repoId]` — 전부 `JwtAuthGuard`. 응답 `RepoResponse`(원시값; `doneCount/totalCount` 는 당시 0 하드코딩 → **4단계에서 업무 status 집계로 교체됨**).

**프론트**
- `shared/utils/format.ts`: `avatarColor`(id 해시)·`initialOf`·`relativeTimeKo`·`progressOf` 추가.
- `types/repo.ts`, `composables/useRepo.ts`, `stores/repo.store.ts`(API→기존 mock `Repo`/`User` 뷰로 매핑: 아바타 3명+`moreCount`, 진행률, 상대시간).
- 페이지 연동: **RepoListPage**(목록·로딩/빈상태), **RepoCreatePage**(생성→상세), **RepoDetailPage**(헤더·진행률만), **RepoSettingsPage**(저장·삭제·이름변경 안내).

### 2.5단계 — Gitea 조직 연동 ✅

**백엔드** `backend/src/modules/gitea/`
- `GiteaService`: 외부 Gitea(`/api/v1`) 얇은 클라이언트(Node 내장 `fetch`, 새 의존성 없음). `createRepo`/`updateRepo`/`deleteRepo` + `enabled` 게이트. `GiteaApiError`(상태코드 보존).
- 환경변수 `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` 셋 다 있어야 활성화. 비어 있으면 저장소 CRUD 가 Gitea 호출 없이 기존대로 동작(로컬/미연동 대비).
- `Repo` 엔티티에 연동 컬럼 추가: `giteaRepoId`, `giteaFullName`, `cloneUrl`, `htmlUrl`(전부 nullable). `RepoResponse` 에 `cloneUrl`/`htmlUrl` 노출.
- `RepoService` 와이어링:
  - **생성**: Gitea 조직에 repo 생성 → 성공 시 Relay 저장(연동정보 보관). 소유자=`GITEA_ORG`. 이름 충돌(409)→`BIZ_001`. Relay 저장 실패 시 Gitea repo 롤백 삭제. (빈 저장소=`auto_init`, 템플릿=`generate` — 아래 생성폼 보강 참조)
  - **수정**: 표시제목/설명→Gitea description, 공개범위→private 동기화(slug 불변). 베스트 에포트.
  - **삭제**: Gitea repo 삭제 동기화. 베스트 에포트(실패해도 Relay 삭제 유지, 에러 로그).
- 인프라 변경 없음(외부 Gitea). 토큰은 JWT 처럼 `backend/.env.{env}` 의 백엔드 고유 시크릿. 커밋용 `backend/.env.{env}.example` 에 키 문서화.

> **프론트 ✅**: 공용 컴포넌트 `components/GiteaRepoBar.vue`(클론 주소 표시·복사 + 'Gitea에서 열기', 미연동 시 렌더 안 함)를 **RepoSettingsPage**(일반 카드)·**RepoDetailPage**(헤더 아래)에 적용. `Repo` 뷰 타입/`repo.store` 매핑에 `cloneUrl`/`htmlUrl`/`giteaMissing` 추가.

### 2.6단계 — Gitea → Relay 역방향 동기화 ✅ (백엔드)

**백엔드** `backend/src/modules/gitea/`, `modules/repo/repo-sync.service.ts`
- 문제: 동기화가 단방향(Relay→Gitea)이라 **Gitea 조직에 직접 만든 저장소가 Relay 목록에 안 떴음**.
- `GiteaService.listOrgRepos()`: 조직 저장소 페이지네이션 순회(`GET /orgs/{org}/repos`). 결과에 `name`/`description`/`private` 추가.
- `RepoSyncService`: **서버 기동 직후 1회(`OnApplicationBootstrap`, 비차단) + 10분마다(`@Cron(EVERY_10_MINUTES)`)** 동기화. `ScheduleModule.forRoot()` 는 `app.module` 에 이미 등록됨.
  - **추가형 + 역동기화(reconcile, 2026-06-19 확장)**: Gitea ID→slug 매칭(중복 매칭 가드). 없으면 import(멤버 0명). 매칭되면 Gitea 네이티브 필드와 1:1 대응되는 **slug(rename)·공개범위·기본 브랜치 + 연동정보를 Gitea 기준(SSOT)으로 끌어온다** → Gitea 에서 직접 이름/공개범위/브랜치를 바꿔도 Relay 가 따라가고 쓰기 경로(slugName)도 일치. **표시명/설명은 Gitea 단일 description 합본 구조라 Relay-SSOT 유지**(외부 편집 시 표시명 깨짐 방지). Relay→Gitea 수정은 `default_branch` 도 함께 푸시(양방향 일치).
  - **누락 표기**: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 `giteaMissing:true` 로만 표기(재출현 시 복구). `Repo.giteaMissing` 컬럼 추가, `RepoResponse`/프론트 `ApiRepo` 에 노출.
  - 미연동 시 건너뜀 + 중복 실행 가드.
  - **import 저장소 멤버 데드락 해소(2026-06-17)**: import 저장소는 멤버 0명이라 admin 이 없어 아무도 멤버를 초대할 수 없는 막힘이 있었음. 동기화 끝에 **`REPO_IMPORT_ADMIN_EMAIL`(env) 의 가입 사용자를 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정**(신규 import + 기존 멤버 0명 모두 대상, Relay 생성분은 생성자가 이미 멤버라 제외). env 미설정/미가입이면 건너뜀(가입 후 다음 동기화에서 지정). 키 문서화: `backend/.env.{env}.example`.
- **목록 모델 변경**: `GET /repos` 가 '내 멤버 저장소' → **'조직 저장소 전체'**(`findAll`). 단건(`findOne`)도 조직 모델상 인증 사용자 열람 허용(쓰기 권한은 기존 admin/owner 유지). 멤버십은 역할/권한 전용.

> **프론트 ✅**: RepoListPage 에 `giteaMissing` '원격 없음' 배지(저장소 행 제목 옆) 표시. 헤더 'Gitea 연동됨' 배지는 실제 연동 여부(`htmlUrl` 보유)에 따라 노출되도록 보정. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상.

### 3단계 — 멤버 관리 ✅ (백엔드 + 프론트)

**백엔드** `backend/src/modules/repo/`
- `MemberService` + `MemberController`(repo 모듈에 흡수, `RepoMember` 엔티티 재사용). 엔드포인트: `GET/POST/PATCH/DELETE /repos/:repoId/members[/:userId]`.
  - **목록**: 조직 모델상 **인증 사용자면 누구나 열람**(생성순 정렬, `RepoService.findOne` 과 동일 정책). 응답 `RepoMemberResponse { user, email, taskCount, roleType, owner }`. (※ 2.6단계 조직 모델 전환 후에도 list 가 옛 '멤버 or 공개' 체크를 유지해 import/타인 생성 저장소 멤버 탭이 403 나던 버그 수정 — 2026-06-17)
  - **초대**: admin 권한. **이미 가입된 사용자만 이메일로 초대**(별도 초대 메일/계정 생성 없음). 미가입 이메일→`BIZ_001`(404), 이미 멤버→`BIZ_001`(409).
  - **역할 변경·제거**: admin 권한. **소유자(owner)는 역할 변경·제거 불가**(`BIZ_001` 403).
- `InviteMemberDto`(email/roleType), `UpdateMemberDto`(roleType?). 단위 테스트 `member.service.spec.ts`(소유자 보호·중복·권한 가드).
- `taskCount` 는 4단계 업무 모듈 도입 전까지 0.
- **Gitea 미동기화**: 인증 모델이 조직 토큰 단일 사용이라 멤버↔Gitea collaborator 동기화는 하지 않음(사용자별 Gitea 계정 매핑은 8단계 후보).

**사용자 검색 API** `backend/src/modules/user/` ✅ (멤버 초대 자동완성용)
- `GET /users?q=<검색어>`(`JwtAuthGuard`) — `q` 있으면 이메일/이름 부분일치(`ILike`, 대소문자 무시), 없으면 전체(이름순, 최대 50). 응답 `PublicUser[]`.
- `UserController` 신규 + `UserService.search()` 추가, `UserModule` 에 컨트롤러 등록. (1단계에서 삭제했던 샘플 user.controller 와 무관한 신규 검색 전용)

**프론트** `frontend/src/` ✅
- `types/member.ts`(`ApiRepoMember`/`InviteMemberPayload`/`UpdateMemberPayload`), `composables/useMember.ts`(list/invite/update/remove), `stores/member.store.ts`(API→화면용 `RepoMember` 매핑: 이니셜·색상 파생, `isYou`=현재 사용자 비교).
- **RepoMembersPage** 목업 제거 → 실제 연동: 멤버 목록(로딩/빈 상태), 헤더·탭 카운트 실데이터, **초대 모달**(이메일+역할, 가입 사용자만, **사용자 자동완성** — 포커스 시 전체·입력 시 이메일/이름 부분일치, 기존 멤버 제외), **역할 변경 드롭다운**(멤버↔관리자), **멤버 제거**(confirm). 초대/역할변경/제거 컨트롤은 **현재 사용자가 admin 일 때만 노출**(`isAdmin`), 소유자(owner) 행은 변경·제거 불가.
- 미가입/중복 초대 등은 인터셉터가 서버 `BIZ_001` 문구를 그대로 노출. `taskCount` 는 4단계까지 0.

### 4단계 — 업무(Task) CRUD + 상태 전이 ✅ (백엔드 + 프론트)

**백엔드** `backend/src/modules/task/`
- `Task` 엔티티: 저장소 종속(`@ManyToOne Repo`, `Relation<>`), `seq`(저장소 내 순번 #N, `@Unique([repo, seq])`), `title`, `content`(jsonb 문단 배열), `status`, `dueDate`, `issuer`(User, SET NULL), `assignees`(`@ManyToMany` → `task_assignees`), `checklist`(`@OneToMany ChecklistItem`, cascade). `ChecklistItem`: `text`/`done`/`sortOrder`.
- `TaskService`: 목록(상태/담당자 필터, seq 내림차순) / 상세 / 생성 / 수정 / 삭제 / `changeStatus`.
  - **담당자 = 저장소 멤버만**: `resolveMemberAssignees` 가 `assigneeIds` 를 멤버십과 대조, 비멤버 포함 시 `BIZ_001`(400).
  - **순번 채번**: 저장소 내 `max(seq)+1`.
  - **상태 머신**: `todo→prog→review→done`, `review→changes`, `changes→{review,prog}`. 허용 외 전이는 `BIZ_001`(409). 역할: 승인/수정요청(review 출발)은 지시자/admin, 그 외 작업 전이는 담당자/지시자/admin. 비멤버는 전이 불가(403).
  - **승인 완료(→done) 부수효과**: 해당 업무의 체크리스트 항목 `done` 을 모두 true 로 **영속 처리**(승인=완료. `done` 은 출구 전이 없어 안전). 디자인 시안의 '승인 완료=체크리스트 100%' 동작 일치.
- `TaskController`: `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]` + `POST .../:taskId/status`(`taskId`=seq, `ParseIntPipe`). 생성/수정/삭제는 admin.
- **저장소 진행률 실집계**: `RepoService` 가 `TaskService.countsByRepo` 로 `doneCount/totalCount` 를 채운다(기존 0 하드코딩 제거). **멤버 `taskCount`**: `MemberService` 가 `TaskService.assigneeCounts` 로 사용자별 담당 수 집계(`RepoModule`→`TaskModule` import). 순환 모듈 없음(TaskModule 은 엔티티만 참조).
- 단위 테스트 `task.service.spec.ts`(상태머신 위반·승인 권한·비멤버 차단·담당자 멤버 검증·**승인 시 체크리스트 일괄 완료**). 백엔드 18 tests pass.
- `comments`/`activities`/`files`/`approval` 은 **5단계 영역** — 응답에 빈 배열로 내려간다.

**프론트** `frontend/src/`
- `shared/utils/format.ts`: `taskStatusLabel`·`taskDueInfo`(dueDate+완료여부→ dateLabel/dday/listLabel/overdue)·`monthDayKo` 추가.
- `types/task.ts`, `composables/useTask.ts`(list/get/create/update/remove/changeStatus), `stores/task.store.ts`(API→mock `RepoTask`/`TaskDetail` 뷰 매핑: 마감/dday/상태 라벨 파생, 지시자 null 폴백).
- 페이지 연동: **RepoDetailPage**(업무 목록 실데이터+로딩/빈상태, 툴바 ‘새 업무’ 버튼), **TaskCreatePage**(제목/내용 textarea/체크리스트/마감일/담당자=멤버 선택 드롭다운 → POST → 생성 상세로 이동), **TaskDetailPage**(상세 fetch, 삭제=DELETE, 승인=review→done·수정요청=review→changes), **TaskAssigneePage**(상세 fetch, 실제 status 로 카드 분기: 시작 todo→prog·승인요청 prog→review·재요청 changes→review, 삭제).
- **업무 수정 UI**: `TaskCreatePage` 를 생성/수정 겸용으로 확장. 라우트 `/repos/:repoId/tasks/:taskId/edit`(`task-edit`) 추가 → `taskId` 있으면 편집 모드(기존 값 미리 채움 + `useTask().get` 로 원시값 로드 + 제출 시 PATCH `taskStore.update`). 헤더/버튼 라벨 전환(업무 수정/저장). 상세·담당자 페이지의 ‘수정’ 버튼을 `…/edit` 로 연결(기존엔 빈 생성 폼으로 오연결). (※ 수정 폼의 체크리스트 항목 추가/삭제는 2026-06-18 에 지원 — `UpdateTaskDto.checklist` 동기화. 제목/내용/담당자/마감과 함께 저장.)
- 댓글/AI 에이전트 패널/첨부 업로드는 **로컬 데모 유지(5단계 실연동)**. 담당자 페이지의 `?state=` 쿼리 의존 제거 → `task.status` 기반 분기.
- **역할 기반 액션 통합(2026-06-17)**: 목록은 항상 `TaskDetailPage`(`…/tasks/:taskId`)로 진입하고, 이 페이지가 **현재 사용자 역할로 액션을 분기**한다. `isIssuer`(task.issuer===me) / `isAssignee`(me ∈ assignees) 계산 → 지시자는 승인 대기(review)에서 승인/수정요청, 담당자는 `업무 시작`(todo→prog)·`승인 요청`(prog→review)·`다시 승인 요청`(changes→review)·승인 대기 중(review) 표시. 담당자가 "업무 시작"에 도달할 경로가 없던 문제 해소(기존 시작 버튼은 `/view`(TaskAssigneePage)에만 있었고 거기로 가는 링크가 mock MyTasksPage뿐이었음). `TaskAssigneePage`/`/view` 는 mock MyTasks 전용으로 잔존(7단계 정리). **한계**: 지시자가 아닌 admin은 화면상 승인 버튼 미노출(편집/삭제는 가능, 백엔드는 admin 승인 허용).
- **UI 정리(2026-06-17)**: 공용 `RepoHeader` 기본 ‘새 업무’ 버튼 제거(`#actions` 슬롯 제공 시에만 렌더) → 상세 헤더 중복 버튼 해소. 작성 폼 ‘임시저장’ 제거, 체크박스 토글 비활성(어느 화면도 사용자 체크 불가), 담당자 칩을 입력칸 아래로 분리, 지시자 직무 줄 제거. 저장소 목록의 진행률 라벨(‘시작 전/67% 완료’) 제거(업무 수·바는 유지).
- **디자인 시안 정렬(2026-06-17)**: 담당자 액션 카드를 시안의 `.ap-card` 색상 체계로 정렬 — 승인요청(prog)=accent(`request`)·수정요청(changes)=빨강·승인완료(done)=초록·승인대기(review)=앰버. ‘업무 시작’도 `request` 톤(인디고)으로 통일. 사이드 상태 뱃지(`.status-badge`)에 `todo/changes/done` 변형 추가(기존엔 review 변형만 있어 나머지가 파란색으로 떨어지던 버그 수정). 카드 본문의 행위자·시각·승인노트는 5·6단계 데이터라 지시자 이름만 넣어 근사.

### 저장소 생성 폼 보강 ✅ (소유자/설명/템플릿 — 백엔드+프론트)

**소유자 하드코딩 제거**
- 백엔드 `GET /repos/meta` → `{ owner, template:{available,slug} }`(컨트롤러에서 `:repoId` 보다 먼저 선언). owner=`GITEA_ORG`(미연동 시 상수).
- 프론트 `useRepo.meta()` + `repo.store`(`owner`/`templateAvailable`/`templateSlug`/`fetchCreateMeta`). **RepoCreatePage** 고정표시·**RepoListPage** 배너의 `marketing-team` 하드코딩 → 실제 조직값.

**프로젝트 설명 필수화**
- `CreateRepoDto.description` 필수(`@IsNotEmpty`, ≤300자). 프론트 생성폼 라벨 `*`·검증 추가. 누락 시 `VAL_001`(400).

**템플릿 복제 생성**
- `GiteaService.generateFromTemplate()` — Gitea generate API(`POST /repos/{tplOwner}/{tplRepo}/generate`, `git_content:true`)로 템플릿 저장소를 복제. 기본 브랜치는 템플릿을 따름.
- 템플릿 출처 env `GITEA_TEMPLATE_OWNER`/`GITEA_TEMPLATE_REPO`(기본 `TtiPo/project-base`, Gitea 에서 *template* 설정 필요).
- `CreateRepoDto.useTemplate=true` 면 빈 저장소 대신 복제 생성. 프론트 생성폼 템플릿 드롭다운=실제 옵션(없음 / `TtiPo/project-base`).
- 라이브 검증: 생성된 repo 의 파일 트리가 `project-base` 와 동일하게 복제됨 확인.

### 5단계 — 업무 상세 부속 ✅ (백엔드 + 프론트)

**백엔드** `backend/src/modules/task/`
- 신규 엔티티: `Comment`(답글은 `parent` 자기참조로 1단계 깊이, `attachment` 선택 연결, author/parent `Relation<>`), `Attachment`(원본명·`storedName`·size·mime·kind, uploader SET NULL). `Task` 에 승인 워크플로우 필드 추가(`approvalNote`/`approvalRequestedBy`/`approvalRequestedAt`/`changesNote`).
- **체크리스트**: **상세 화면 읽기 전용**(완료 토글은 사용자가 하지 않음 — 승인 완료 시 일괄 처리, 4단계 부수효과). **항목 추가/삭제(정의)는 업무 생성·수정 폼에서** 한다: 생성=`CreateTaskDto.checklist`, 수정=`UpdateTaskDto.checklist`(id 기준 전체 동기화 — 기존 유지·완료 보존, 신규 추가, 빠진 항목 삭제). — *2026-06-18: 5단계 초기의 항목별 토글 API 제거 → 수정 PATCH 로 통합*
- **댓글/답글**: `POST .../comments`(`text`/`fileId?`), `POST .../comments/:commentId/replies` — 멤버. 답글은 최상위 댓글에만.
- **파일 첨부**: `POST .../files`(multipart, `FileInterceptor` + `dest`), `DELETE .../files/:fileId`, `GET .../files/:fileId/download`(`@Res` 직접 스트리밍, `Content-Disposition` UTF-8) — 멤버. 비ASCII 파일명 latin1→UTF-8 복원, MIME/확장자로 `kind` 파생.
- **승인 워크플로우**: `POST .../approval/request`(prog/changes→review, note 선택), `/approve`(review→done), `/changes`(review→changes, note 필수). 4단계 상태머신 전이 로직을 `transition()` 으로 추출해 `POST .../status` 와 공유(역할 검증 동일). 승인 정보는 review 상태일 때만 `approval{requester,requestedAt,note}` 로 노출, changes 상태는 `changesNote`.
- `TaskDetailResponse` 에 `comments`/`files`/`approval`/`changesNote` 실데이터 채움. 목록 `commentCount` 실집계. `activities` 는 6단계라 빈 배열 유지.
- **파일 저장 방식(결정됨)**: **서버 로컬 업로드 폴더**(`upload.config.ts` `UPLOAD_DIR`, 기본 `cwd/uploads`). `@types/multer` 의존 없이 `UploadedDiskFile` 인터페이스 자체 정의. 운영 Dockerfile 이 `/app/uploads` 를 `node` 소유로 생성(비-root 쓰기 가능). dev 는 `./backend:/app` 바인드라 `backend/uploads`(gitignore)로 영속, 운영 영속이 필요하면 해당 경로에 볼륨 마운트(추후). S3/MinIO 는 후순위.
- 테스트 `task.service.spec.ts` 보강(승인 요청/수정요청 역할, 체크리스트 비멤버 차단). 백엔드 **21 tests pass**, build/lint 0 err.

**프론트** `frontend/src/`
- `shared/utils/format.ts`: `formatBytes`(바이트→"18.4 MB"). `types/task.ts`: `ApiAttachment`/`ApiComment`/`ApiCommentReply`/`ApiApproval` + 부속 페이로드. `composables/useTask.ts`: 체크리스트/댓글/답글/파일(FormData)/승인 메서드. `stores/task.store.ts`: 댓글·첨부·승인 뷰 매핑(작성자 시간·역할배지·바이트 변환), 부속 변경 후 상세 재조회로 일관 갱신.
- mock 뷰 타입(`ChecklistItem`/`Attachment`/`Comment`/`Reply`/`TaskDetail`)에 옵셔널 `id`/`url`/`changesNote` 추가(연동용, 기존 화면 호환).
- **TaskDetailPage**: 체크리스트는 읽기 전용(표시만), 댓글/답글 실연동(로컬 데모 제거), 첨부 카드 항상 표시+업로드/다운로드 링크/삭제, 승인 요청 메모(prog/changes 카드 textarea)·수정 요청 메모(모달 textarea) 전송, 지시자 승인 카드에 요청 메모 노출·담당자 수정요청 카드에 보완 메모 노출. type-check/lint/build 0 err.

> **한계**: 댓글에 파일 첨부(`fileId`)는 백엔드만 지원(상세 화면 UI 미노출). 업무 삭제 시 디스크 파일은 베스트에포트 정리(DB 행은 FK CASCADE). AI 에이전트 패널은 여전히 로컬 데모(8단계).

### 6단계 — 활동 피드 ✅ (백엔드 + 프론트)

**백엔드** `backend/src/modules/activity/`
- `Activity` 엔티티: `repo`(FK, CASCADE)·`taskSeq`(int nullable, 업무 활동 필터용 — Task FK 대신 seq 저장)·`actor`(User SET NULL)·`type`·`payload`(jsonb)·`createdAt`. 표시 문구/아이콘/톤은 저장하지 않고 **프론트가 type+payload 로 조립**(구조화 채택).
- `ActivityService`: `record()`(베스트 에포트 적재 — 실패해도 본 흐름 안 막음), `listForRepo(slug)`(최신순 200건), `listForTask(repoId, seq)`. `ActivityController`: `GET /repos/:repoId/activity`(인증, 조직 모델상 누구나 열람).
- **이벤트 자동 적재**(다른 서비스가 `ActivityService.record` 호출): RepoService(생성/이름변경/공개범위), MemberService(초대/역할변경/제거), TaskService(생성·상태전이[`transition` 단일 지점]·댓글/답글·파일 첨부/삭제·업무 삭제·**업무 수정[변경 측면별: 담당자/마감/일반]**). 상태전이는 `task.status_changed` 단일 타입 + `payload{statusFrom,statusTo}` 로 적재하고 프론트가 from→to 로 문구 파생(시작/승인요청/승인/수정요청). 업무 수정(`update`)은 이전 상태와 비교해 **실제 바뀐 측면만** 적재(`task.assignees_changed`/`task.due_changed`/`task.edited`). `ActivityModule` 을 RepoModule·TaskModule 이 import(Activity 는 taskSeq 만 보관해 Task 엔티티 미참조 → 순환 없음).
- `TaskDetailResponse.activities` 를 `ActivityService.listForTask` 로 채움(기존 빈 배열 제거).

**프론트** `frontend/src/`
- `format.ts`: `dayGroupKo`(오늘/어제/이번 주/날짜), `escapeHtml`(XSS 방지). `types/activity.ts`(`ApiActivity`), `composables/useActivity.ts`, `stores/activity.store.ts`(type+payload→톤/아이콘/문구 조립 + 날짜 그룹핑). **사용자 입력(이름/제목/파일명/표시명)은 escapeHtml 후 신뢰 태그만 조립**해 v-html 렌더.
- **RepoActivityPage**: mock(`REPO_ACTIVITY`/`findRepo`) 제거 → 실연동(저장소 헤더=repo.store, 활동=activity.store, tone 필터, 로딩/빈 상태).
- **TaskDetailPage 활동 탭**: `task.store` 의 `toTaskActivityView` 로 API 활동을 시안 Activity 뷰(tone/icon/html/statusFrom·To/file)로 매핑 → 실데이터 표시.
- 검증: 프론트 type-check/lint/build 0 err, 백엔드 build/lint 0 err·20 tests pass.

> **한계**: 활동은 **6단계 도입 이후 발생분만** 기록(소급 적재 없음). 활동 문구 내 업무 링크는 표시(`.tgt`)만 하고 클릭 라우팅은 생략. 체크리스트 개별 완료는 별도 이벤트 없이 승인 완료(`status_changed`→done)로 일괄 표현. 저장소 설명/브랜치 변경은 미적재.
> *(2026-06-18 점검·보강: 업무 수정[담당자/마감/일반]·답글·업무 삭제·첨부 삭제 이벤트 추가.)*

---

### 7단계 — 내 업무 집계 ✅ (백엔드 + 프론트)
- 백엔드 `MyTaskController` `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단, `TaskService.listAssigned/listIssued`, 마감 임박순). 플랫 행 배열 반환, 그룹핑·라벨은 프론트 파생.
- 프론트 `format.ts` `taskDueInfo`에 `ddayLevel` 추가, `types/my-task.ts`/`composables/useMyTask.ts`/`stores/my-task.store.ts`(담당/지시 뷰별 그룹 순서·라벨·미니아바타·승인대기 강조). **MyTasksPage** mock 제거→실연동(로딩/빈상태).
- **`TaskAssigneePage`(`/view`)·`task-assignee-view` 라우트·파일 제거 완료**(상세가 역할 기반 통합). 행 클릭은 항상 `…/tasks/:taskId`.
- 라이브 검증: `/tasks/assigned`·`/tasks/issued` 200·정상.

---

## 3. 남은 단계

### 8단계 — 마무리(선택) · **진행 중**

#### 8-1. 페이지네이션 통일 — 백엔드 ✅ / 프론트 전 도메인 ✅

**결정(2026-06-18)**: 전체 목록 통일 + 프론트는 **더보기 버튼** 방식(시안에 페이지 UI 없음).

**백엔드 ✅ (완료)**
- 공통 유틸 `backend/src/common/pagination/pagination.ts`: `resolvePage(page,size)`(범위 보정 + skip/take), `paginated(items,total,pg)`, `PaginatedResult<T>{items,total,page,size}`. 기본 size 20, 최대 100.
- 컨트롤러는 `@Query('page', DefaultValuePipe(1), ParseIntPipe)` / `size` 로 받아 서비스에 전달.
- 적용: `GET /repos`(RepoService.findAll), `GET /repos/:id/members`(MemberService.list), `GET /tasks/assigned`·`/tasks/issued`(TaskService.listAssigned/listIssued). 응답이 배열→`{items,total,page,size}` 로 변경.
- `GET /repos/:id/tasks`(TaskService.list)는 **세그먼트(all/prog/todo/done)·검색(q)·페이지네이션 + 세그먼트 카운트** 동봉(`RepoTaskListResult = PaginatedResult<RepoTaskResponse> & { counts }`). 세그먼트 prog=prog+review, changes는 전체에만 집계(기존 RepoDetailPage 동작 보존). 기존 `?status` 쿼리는 `?segment` 로 대체.
- 검증: 백엔드 build/lint 0 err, 20 tests pass(member spec 의 list 응답 `{items,total}` 으로 갱신).

**프론트 — 2/4 도메인 ✅**
- 공통 타입 `types/pagination.ts`(`Paginated<T>`, `DEFAULT_PAGE_SIZE=20`), 전역 `.load-more` 버튼 스타일(relay.css).
- ✅ **저장소 목록**: `useRepo.list(page,size)`→`Paginated`, `repo.store`(total/page/hasMore/loadingMore + `loadMore` append), RepoListPage 하단 더보기 + total 카운트. 검색/공개여부 필터는 클라 유지(저장소 소규모).
- ✅ **내 업무**: `useMyTask.assigned/issued(page,size)`, `my-task.store`(원시 행 누적 + 그룹핑은 computed 로 파생 → 더보기 시 같은 그룹에 합류, 담당/지시 각각 total/page/hasMore + `loadMoreAssigned`/`loadMoreIssued`), MyTasksPage 뷰별 더보기.
- 검증: 프론트 type-check/lint/build 0 err.

**응답 계약 회귀 수정 ✅ (2026-06-19, `c128b19`)**
- 백엔드 페이지네이션 도입으로 목록 API 가 `{items,total,...}` 를 반환하는데 프론트 멤버·업무 소비처가 **배열로 기대**해 `list.map` 이 깨지고 멤버 탭·업무 목록이 **비어 보이던 회귀**를 수정.
  - 프론트: `useMember.list`·`useTask.list` 가 `Paginated<T>` 반환하도록 맞추고, `member.store`·`task.store.fetchList` 에서 `.items` 로 언래핑(저장소·내업무와 동일 계약). → **첫 페이지(기본 20건)는 정상 표시.**
  - 백엔드(task): 업무 목록 쿼리가 to-many 조인 컬럼(`checklist.sort_order`)으로 정렬해 페이지네이션 시 TypeORM 이 크래시하던 문제 제거 → `task.seq` 만 정렬(목록은 체크리스트 [완료/전체] 수만 쓰므로 항목 순서 불필요).
- ⚠️ 이전의 '전환기 화면 깨짐' 위험은 **이 커밋으로 해소**. 단 더보기/세그먼트 이전은 아직이라 **첫 페이지 이후 항목은 보이지 않음**(아래 ⏳).

**프론트 — 남은 2/4 도메인 ✅ (2026-06-19)**
- ✅ **멤버**(RepoMembersPage): **더보기 대신 전체 로드 채택**. `member.store.fetchList` 가 페이지네이션 API를 끝까지 순회(`while items`)해 전체 멤버를 적재한다. 이유: ① 역할/검색 세그먼트 카운트(전체/관리자/멤버)가 **전수 기준**이어야 하고(부분 로드 시 합계 불일치), ② **TaskCreatePage 가 담당자 후보 풀로 같은 `fetchList` 를 사용**하므로 전체가 보장돼야 하며, ③ 멤버는 팀 규모로 한정적이라 전체 로드가 안전. → 두 소비처(멤버 페이지·업무 생성 폼) 모두 페이지 수정 없이 전체 셋 확보. (더보기 UI 불필요)
- ✅ **저장소 업무**(RepoDetailPage): 세그먼트/검색을 **백엔드로 이전** — `task.store` 에 `total`/`page`/`counts`/`hasMore`/`loadingMore` + `loadMore` 추가, `useTask.list(repoId, {segment,q}, page, size)` 가 `{items,total,counts}` 반환. 페이지가 `segment`(즉시)·`keyword`(250ms 디바운스) 변경 시 1페이지 리셋 로드, 더보기로 다음 페이지 append. 세그먼트 배지 수는 응답 `counts`(검색어 반영) 사용. 타입 `TaskSegment`/`TaskSegmentCounts`/`ApiRepoTaskListResult` 신설, 기존 `TaskListQuery.status` → `{segment,q}` 로 교체.
- 검증: 프론트 type-check/lint/build 0 err.

> **알려진 한계**: RepoDetailPage 진행률 스트립의 '지연 N건'은 **현재 로드된 목록 기준**(전역 정확 집계는 백엔드 미지원). 업무 삭제 시 세그먼트 카운트는 목록 재진입(재마운트) 시 재조회로 갱신(`remove` 는 총계만 보정).

#### 8-2. 실시간 알림 (인앱 + SSE) ✅ (2026-06-19)

**전송 방식 결정**: **SSE(Server-Sent Events)** 채택. 알림은 서버→클라이언트 단방향 푸시라 SSE 가 정확히 맞고, HttpOnly 쿠키 인증(EventSource `withCredentials`)과 호환되며 NestJS `@Sse()` 네이티브 지원이라 **새 의존성 없음**(WebSocket 은 socket.io 등 추가 필요). 단일 인스턴스 가정 → 인메모리 pub/sub(RxJS Subject).

**백엔드** `backend/src/modules/notification/`
- `Notification` 엔티티: `recipient`(User, CASCADE)·`type`·`payload`(jsonb)·`read`·`createdAt`. 복합 인덱스 `[recipient, createdAt]`. (활동 피드와 별개 — 활동=저장소 전체 로그, 알림=개인 수신함)
- `NotificationService`: `notify({recipientIds, actorId, type, payload})`(행위자 자동 제외 + 베스트 에포트 적재 후 스트림 푸시), `listFor`(페이지네이션 + `unreadCount`), `markRead`/`markAllRead`, `streamFor(userId)`(RxJS Subject 를 userId 로 필터 + 25초 하트비트). 인메모리 `Subject<{userId,data}>` 하나를 구독 시 필터.
- `NotificationController`: `GET /notifications`(목록+미읽음), `POST /notifications/:id/read`, `POST /notifications/read-all`, **`@Sse('stream')`**(실시간). SSE 는 `@SkipTransform()` 로 표준 래퍼(`{success,data}`)를 건너뛴다(신규 `common/decorators/skip-transform.decorator.ts` + `TransformInterceptor` 가 Reflector 로 마커 확인).
- **적재 지점**(다른 서비스가 `notify` 호출, **8종**): TaskService — 업무 생성→담당자(`task.assigned`), 상태전이 `transition` 단일 지점에서 review→지시자(`task.approval_requested`)·done→담당자(`task.approved`)·changes→담당자(`task.changes_requested`), 댓글/답글→관련자(`task.commented`, **답글은 원댓글 작성자 포함**), 업무 수정 시 새 담당자(`task.assigned`). MemberService — 초대→피초대자(`member.invited`), **역할 변경→당사자(`member.role_changed`)**, **제거→당사자(`member.removed`)**. NotificationModule 을 TaskModule·RepoModule 이 import(순환 없음 — Notification 은 User 만 참조).
  - *(2026-06-19 보완: P1[멤버 역할변경/제거, 답글→원댓글 작성자] + P2[담당 해제 `task.unassigned`·마감 변경 `task.due_changed`] + P3[삭제 `task.removed`·시작 `task.started`·답글 `isReply` 구분] → 알림 **12종**, gap 전건 해소. 전수 검토는 [`notification-review.md`](./notification-review.md))*

**프론트** `frontend/src/`
- `types/notification.ts`, `composables/useNotification.ts`(list/markRead/markAllRead — SSE 는 axios 비대상이라 store 에서 EventSource 직접 처리), `stores/notification.store.ts`(items/unreadCount/total + `connect`(멱등, 최초 1회 목록 로드 + 스트림 오픈)/`disconnect`/`loadMore`/`markRead`(낙관적)/`markAllRead`, type+payload→톤/문구/이동경로 뷰 매핑, **사용자 입력 escapeHtml 후 신뢰 태그만 v-html**).
- **AppShell**: 장식이던 상단바 종 버튼 → 실연동. **미읽음 뱃지**(99+ 캡), **드롭다운**(목록·모두 읽음·더보기·빈 상태), 항목 클릭=읽음 처리 + 대상(`/repos/:id/tasks/:seq` 또는 `/repos/:id`)으로 이동. 스트림은 store 싱글톤이 보유 → 화면 전환에도 끊기지 않고, **연결은 AppShell 마운트 시(로그인 상태) 1회, 해제는 로그아웃 시**.
- 검증: 백엔드 build/lint 0 err·20 tests pass, 프론트 type-check/lint/build 0 err.

> **알려진 한계**: ① ~~단일 인스턴스 전용~~ → **다중 인스턴스 지원 완료(2026-06-19, R6)**: `REDIS_HOST` 설정 시 Redis pub/sub(ioredis)로 fan-out — `notify` 가 채널 `relay:notifications` 에 발행, 모든 인스턴스가 구독해 실시간 전달. 미설정 시 인메모리 폴백. ② access 토큰(15분) 만료 후 SSE 가 끊겨 재연결되는 시점에 쿠키가 만료돼 있으면, 다음 axios 호출이 토큰을 갱신할 때까지 5초 간격 무알림 재시도(유휴 상태 한정, 사용 재개 시 자동 복구). ③ 알림은 **도입 이후 발생분만**(소급 없음), 이메일/푸시는 범위 밖.

#### 8-3. Redis 캐싱 ✅ (2026-06-19)

**스택**: `@nestjs/cache-manager@3` + `cache-manager@6`(Keyv 기반) → Redis 연결은 신규 의존성 **`@keyv/redis`**. docker-compose 가 이미 redis 컨테이너 + `REDIS_HOST/PORT/PASSWORD` 주입.

**백엔드**
- **CacheModule → Redis 전환**(`app.module.ts` `registerAsync`): `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, **없으면 인메모리 폴백**(REDIS_HOST 미설정 시). keyv `error` 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초.
- **저장소 목록 캐싱**(`GET /repos`): 신규 `RepoCacheService`(repo 모듈) — **버전 네임스페이스 무효화**. 키 `repos:list:v{ver}:p{page}:s{size}`, 변경 시 `repos:list:ver` 증가로 기존 키 일괄 폐기(keyv 와일드카드 삭제 미지원 회피). 모든 캐시 접근은 try/catch 로 실패 시 DB 조회 폴백.
  - **무효화 지점**: RepoService 생성/수정/삭제 + RepoSyncService 동기화 + **MemberService 초대/제거**(목록 카드 멤버 수·아바타 반영, 역할 변경은 목록 불변이라 제외 — *2026-06-19 보강*). `RepoCacheService` 공유.
  - **보강(2026-06-19, 검토 [`redis-cache-review.md`](./redis-cache-review.md))**: ① C1 멤버 변경 무효화, ② 기동 시 캐시 모드 로그(Redis/인메모리 가시화), ③ `main.ts` `enableShutdownHooks()` — 종료 시 알림 Redis pub/sub 연결 graceful close(이전엔 훅 미설정으로 `onModuleDestroy` 가 死코드였음).
  - **업무 카운트 staleness**: 업무 상태 변경은 목록 버전을 올리지 않아 `doneCount/totalCount` 는 최대 60초(TTL) 지연 — 대시보드 특성상 허용(정확 무효화 대신 task↔repo 결합 회피).
- **캐싱 제외**: `GET /repos/meta` 는 순수 인메모리 getter(DB 미접근)라 Redis 왕복이 오히려 손해 → 캐싱 안 함.

**검증**: 백엔드 build/lint 0 err·20 tests pass. (프론트 변경 없음 — API 계약 동일, 응답만 캐시.)

> **알려진 한계**: ① 캐싱 대상은 현재 저장소 목록 1종(가장 트래픽 큰 랜딩) — 업무 목록 등은 세그먼트/검색/페이지 조합이 많아 추후 확장 후보. ② 업무 카운트 ≤60초 지연(위). ③ **의존성 추가(`@keyv/redis`)라 dev 컨테이너는 `up -d --build -V`(익명 node_modules 볼륨 갱신) 필요.**

#### 8-4. 소셜 로그인 + 회원가입 이메일 인증 ✅ (2026-06-19)

**지시(2026-06-19)**: 소셜 키는 백엔드 env 에 등록됨 → 바로 연동. 회원가입은 인증 메일의 버튼 클릭 시 완료. **메일 전송은 우선 백엔드 로그로 대체.**

**백엔드**
- **User 엔티티 확장**(`modules/user/entities/user.entity.ts`): `passwordHash` **nullable**(소셜 전용 계정=null), `provider`('local'|'google'|'kakao', default 'local'), `emailVerified`(bool, default false), `emailVerifyTokenHash`/`emailVerifyExpiresAt`(둘 다 select:false). 원문 인증 토큰은 메일 링크에만 담고 DB엔 **SHA-256 해시 + 만료(24h)** 만 저장.
- **그랜드페더링**(`UserService.onApplicationBootstrap`): 인증 도입 이전 로컬 계정 락아웃 방지 — `provider='local' & 토큰 null & 미인증` 행을 인증완료로 보정(멱등 — 진행 중 미인증 계정은 토큰을 보유해 제외).
- **메일 모듈**(신규 `modules/mail/`): `MailService.sendVerificationEmail()` 플레이스홀더 — **인증 링크를 백엔드 Logger 로 출력**(추후 SMTP 도입 시 이 서비스만 교체).
- **AuthService**: `signup` **자동 로그인 폐지** → 미인증 계정 생성 + 인증 메일 발송, 응답 `{email, verificationPending}`. `verifyEmail(token)`(해시 매칭 + 만료 검사 → 인증 완료, 토큰 폐기), `resendVerification`(존재/상태 비노출), `validateUser` 에 **로그인 게이트**(미인증 로컬=`BIZ_001` 403, 비밀번호 없는 소셜 전용=소셜 안내), `validateOrCreateOAuthUser`(이메일로 매칭→없으면 생성 emailVerified=true, 미인증 로컬과 이메일 같으면 인증 보정).
- **소셜 전략**(passport-google-oauth20·passport-kakao 신규 의존성, kakao 무타입→`auth/types/passport-kakao.d.ts` 앰비언트 선언): `GoogleStrategy`/`KakaoStrategy` 가 프로필 정규화 후 위임. **키(`*_CLIENT_ID`) 미설정 시 전략 미등록**(AuthModule `useFactory` 가드 — null 반환).
- **AuthController**: `signup`(쿠키 미발급)·`resend-verification`·**`GET verify-email`**(`@SkipTransform`+`@Res` 302 → `FRONTEND_URL/login?verified=1|0`)·**`GET google|kakao` + `/callback`**(`@SkipTransform`, 콜백서 쿠키 발급 → `FRONTEND_URL/repos`). `loginWith` 헬퍼로 토큰 발급+쿠키 공통화.
- **env**: `APP_API_URL`(메일 링크용 백엔드 주소, 폴백 `localhost:3100/api`)·`FRONTEND_URL`(리다이렉트, 폴백 CORS_ORIGIN→5273) + `GOOGLE_*`/`KAKAO_*`(콜백 URL 은 백엔드 포트 **3100**, `/api/auth/{provider}/callback`) → `backend/.env.development.example` 문서화.

**프론트**
- `types/auth.ts` `SignupResult`, `useAuth`/`auth.store`: signup→대기(user 미갱신) + `resendVerification`.
- **SignupPage**: 가입 성공 시 자동 로그인 대신 **인증 메일 안내(verify 단계)** 표시 + 재발송 버튼 연동.
- **LoginPage**: 소셜 버튼 실연동(`window.location.href = ${VITE_API_BASE_URL}/auth/{provider}` 전체 페이지 이동) + `?verified=1|0` 결과 배너.

**검증**: 백엔드 build/lint 0 err·20 tests pass(MailService 가 AuthService 4번째 생성자 인자 — 영향 spec 없음), 프론트 type-check/lint/build 0 err.

> **알려진 한계**: ① **런타임 미검증** — DB + 소셜 콘솔에 콜백 URL 등록 필요(코드 레벨까지). ② 메일은 **로그 출력**(실발송 SMTP 미구현). ③ **의존성 추가(passport-google-oauth20/passport-kakao)라 dev 컨테이너 `up -d --build -V` 필요.** ④ 소셜 계정은 이메일 기준 매칭(별도 providerId 컬럼 없음) — 동일 이메일이면 동일인으로 연동.

#### 8-5. 보안 점검 + 보완 (배치 A+B) ✅ (2026-06-19)

전 작업분 대상 보안 감사(4영역 병렬) → `docs/security-review.md`. 서버측 기반은 견고(IDOR/권한상승/SQL인젝션/에러누출 없음), 최약점은 신규 소셜 로그인. 사용자 결정: **HIGH+MEDIUM 적용**, 조직 전체 읽기 모델은 의도된 설계로 유지.

- **H1** OAuth 제공자 이메일 검증 미확인(계정 탈취/선점) → 전략이 `email_verified`/`is_email_verified` 를 읽어 전달, 미검증 시 403 거부.
- **H2** OAuth `state` 누락(로그인 CSRF) → 세션 없는 구조라 `CookieStateStore`(httpOnly 1회용 state 쿠키) 신설, 두 전략에 주입(새 의존성 0).
- **H3** refresh 회전/폐기 부재 → (초기) `User.tokenVersion` 폐기 → **(강화) 세션 기반 완전 회전 + 재사용 탐지로 교체**. `RefreshSession`(패밀리, 한 로그인=1행: id/currentJti/prevJti+prevAt/expiresAt) 도입. `/auth/refresh` 가 현재 jti면 새 jti로 회전, 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 `useApi` single-flight 로 방지. logout=패밀리 폐기, `revokeAllSessions`(전 기기) 준비. `tokenVersion`·`jwt-refresh.strategy`·`JwtRefreshGuard` 제거(refresh 는 가드 없이 서비스 직접 처리).
- **M1** 업로드 MIME 화이트리스트(`fileFilter`, html/svg 차단)+거부 400, 다운로드 `attachment`+`nosniff`(저장형 XSS 차단).
- **M2** `@Throttle` 라우트별(login 10·signup 5·resend 3 /분).
- **M3** 첨부 삭제 업로더/admin 한정.
- **M4** 인증 토큰 로그 prod 게이트(원문 토큰 운영 로그 미기록).

검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. **런타임 미검증**(OAuth 제공자 콜백+DB 필요).

**배치 C (LOW 하드닝, L2~L6) ✅** — L1(에이전트 v-html)은 에이전트 패널 미연동이라 사용자 요청으로 보류.
- **L2** DTO 길이 바운드(task `content`/`assigneeIds`/`checklist` `@ArrayMaxSize`+요소 `@MaxLength`, `UpdateRepoDto.description` `@MaxLength(300)`).
- **L3** 비번 정책(`@MaxLength(72)`+영문·숫자 `@Matches`) + bcrypt 10→12.
- **L4** 그랜드페더링 컷오프(`2026-06-20` 이전 생성 한정, `createdAt LessThan`).
- **L5** multer 오류 → 400(VAL_001) 매핑(전역 필터).
- **L6** CORS `localhost:3000`·Swagger `/api-docs` 비운영 한정.
검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음.

#### 8-6. AI 채팅 패널 ✅ (2026-06-19)

**지시**: 기존 목업 UI(가짜 "relay order draft" 터미널)는 버리고, **Claude API로 채팅만** 되도록. 키는 백엔드 env(`CLAUDE_API_KEY`)에 등록됨.

- **백엔드** 신규 `modules/ai/`: `AiService`(Anthropic Messages API 프록시 — 글로벌 `fetch` + `AbortSignal.timeout(30s)`, 키는 서버 env 만, 미설정 503·외부오류 502·내부정보 비노출), `AiController` `POST /ai/chat`(JwtAuthGuard + `@Throttle` 20/분), `ChatDto`(role enum + content MaxLength 8000 + 메시지 ArrayMaxSize 50). 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). `AiModule` → app.module.
- **프론트** 신규 `components/AgentChatPanel.vue`(우측 슬라이드 채팅 패널, 환영문구·말풍선·타이핑 인디케이터·에러·새대화, plain text + `white-space:pre-wrap`로 XSS 안전), `types/ai.ts`·`composables/useAi.ts`(대화 이력 전체 전송, 서버 무상태). **TaskDetailPage 의 기존 목업 에이전트 패널(스크립트·템플릿·전역 스타일 전부) 제거**. 이로써 보안 L1(에이전트 v-html)도 해소.
- **버튼은 AppShell 헤더로 이동 — 모든 화면에서 상시 노출/열림**(`agentOpen` 은 AppShell 가 보유, `<AgentChatPanel>` 도 AppShell 에서 렌더).
- **DB 영속 + 다중 대화로 확장(2026-06-20)**: 사용자 요청 "어디서든 본인 기록 조회 → DB 필요". 무상태 `/ai/chat` → **대화 스코프 API**(`AiConversation`·`AiMessage` 엔티티, user CASCADE). 엔드포인트 `GET /ai/conversations`(목록)·`GET/:id`(상세)·`POST /ai/conversations`(새 대화)·`POST /:id/messages`(추가)·`DELETE /:id` — 전부 **본인 소유 스코프**(타인 대화 404=IDOR 차단). **클라는 새 메시지 1건만 보내고 서버가 DB 이력으로 Claude 호출**(보안·페이로드 개선). **Claude 실패 시 사용자 메시지·빈 대화 롤백**. 프론트 `AgentChatPanel` 에 **채팅↔지난 대화 목록 뷰**(열기/삭제), `ai.store` 가 목록·현재 대화 관리. **이제 다른 기기·재로그인·새로고침에도 기록 유지.** SendMessageDto(content 8000자). 검증 백엔드 build/lint 0·20 tests, 프론트 0. 런타임 미검증(키·DB 필요).
- 검증: 백엔드 build/lint 0·20 tests, 프론트 type-check/lint/build 0. **런타임 미검증**(실제 API 키 호출 필요). 모델 ID는 콘솔 가용 모델로 `CLAUDE_MODEL` 교체 가능.

#### 8-7. 그 외(미착수)
- 메일 실발송(SMTP), 비번 재설정 플로우, 보안 후속(계정잠금/CAPTCHA).

---

## 4. 전환기 한계 (현재 시점)

- **모든 화면이 실데이터로 연동 완료.** 남은 미연동 요소는 **AI 에이전트 패널(로컬 데모, 8단계)** 뿐.
- `src/mock/relay.mock.ts` 는 이제 **타입 정의 공급원으로만** 사용(여러 store/page 가 `Repo`/`User`/`TaskStatus`/`RepoTask`/`TaskDetail`/`Activity`/`MyTaskGroup` 등 뷰 타입을 import). 정적 데이터(`REPO_ACTIVITY`/`MY_TASKS_*`/`REPOS`/`USERS` 등)는 사용처가 없어졌다 — 추후 타입만 `src/types/` 로 이전하고 데이터는 제거 가능(현재는 유지).

---

## 5. 알아둘 사항 / 결정 기록

- **엔티티 순환참조**: 서로 참조하는 관계(예: `Repo`↔`RepoMember`)의 **단일 클래스 관계 속성에는 TypeORM `Relation<>` 래퍼**를 사용한다. 안 그러면 SWC 환경에서 `Cannot access 'X' before initialization` 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용.
- **raw 집계 쿼리용 FK 컬럼명**: `createQueryBuilder().select('task.repo_id', …)` 처럼 **raw SQL 로 FK 컬럼을 직접 참조**하면, `@ManyToOne` 의 기본 FK 컬럼명이 camelCase(`repoId`)라 `task.repo_id`(snake) 와 불일치해 `42703 column does not exist` 가 난다. → 관계에 **`@JoinColumn({ name: 'repo_id' })` 를 명시**해 컬럼명을 snake_case 로 고정(프로젝트 컬럼 네이밍과도 일치). `Task.repo` 에 적용. (`task.repo = :id` 같은 관계 쿼리는 자동 매핑되어 무관)
- **bcrypt → bcryptjs**: Docker 베이스가 `node:20-alpine`(빌드 도구 없음)이라 네이티브 모듈 `bcrypt` 대신 순수 JS `bcryptjs` 사용.
- **dev 도커 익명 볼륨**: `/app/node_modules` 가 익명 볼륨이라 **의존성 변경 시 `up -d --build -V`(익명 볼륨 갱신)** 필요. `--build` 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음.
- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. **Gitea 연동 시 owner=`GITEA_ORG`** 로 대체(미연동 시 상수 폴백). 조직 도입 시 교체.
- **ID 전략**: User/Repo PK=UUID, Repo 라우팅 식별자=`slugName`, Task=저장소 내 순번(#9, 4단계).
- **Gitea 연동 방식**: HTTP 클라이언트는 새 의존성(@nestjs/axios) 없이 Node 20 내장 `fetch` 사용(베이스 이미지 alpine 호환). 인증은 조직 토큰 1개(사용자별 Gitea 계정 매핑은 8단계 후보). `slug`↔Gitea repo name 1:1, 한글 표시제목은 Gitea description 에 합쳐 저장(Gitea 에 표시명 필드 없음).
- **연동 비활성 폴백**: `GITEA_*` 미설정 시 `GiteaService.enabled=false` → 저장소 CRUD 가 Gitea 없이 동작. 로컬 개발/테스트에서 Gitea 없이도 기존 흐름 유지.
- **저장소 생성 폼 보강**: ① **프로젝트 설명 필수화**(`CreateRepoDto.description` required, 프론트 검증 추가). ② **템플릿 복제** — `useTemplate=true` 면 Gitea generate API 로 `GITEA_TEMPLATE_OWNER/REPO`(기본 `TtiPo/project-base`, *template* 설정 필요) 저장소를 복제 생성(브랜치는 템플릿을 따름). 프론트는 `GET /repos/meta`(owner+template) 로 owner 고정표시·템플릿 옵션을 받아 하드코딩 제거.
- **import 저장소 멤버 데드락 / `REPO_IMPORT_ADMIN_EMAIL`**: import 저장소는 멤버 0명 → admin 부재 → 초대 불가의 데드락. env(`REPO_IMPORT_ADMIN_EMAIL`)로 지정한 가입 사용자를 동기화 시 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정해 해소. (선택지 중 "env 지정 이메일" 채택 — 동기화는 시스템 작업이라 실행 주체가 없기 때문)
- **`subRole`(저장소 내 직무) 제거**: 표시/권한 어디에도 관여하지 않는 장식 라벨이라 백엔드 엔티티·DTO·응답 + 프론트 타입·표시·초대입력에서 **완전 제거**(2026-06-17). owner 에 박던 `'리드'` 기본값도 제거. dev `synchronize` 가 `repo_members.sub_role` 컬럼을 드롭함. (권한은 `roleType`(admin/member)만 사용. 전사 직무 `User.role` 은 별개로 유지)
- **env 변경 시 컨테이너 재생성 필요**: `docker-compose` 의 `env_file`(루트·`backend/.env.*`)은 **컨테이너 시작 시 1회만** 주입된다. `nest start --watch` 의 핫리로드는 **코드만** 다시 읽고 `process.env` 는 갱신하지 않으므로, env 값을 바꾸면 `up -d`(설정 변경 감지 시 재생성) 또는 `up -d --force-recreate <서비스>` 로 컨테이너를 다시 만들어야 한다. `docker compose restart` 는 env_file 을 다시 읽지 않음. (코드 변경만이면 watch 가 자동 반영)
- **env 파일 구조 정규화**: 각 실제 `.env.{env}` 와 그 `.env.{env}.example` 은 **주석·키 순서·빈 줄까지 동일**하게 맞추고 차이는 값(실제 secret ↔ placeholder)만 두도록 정리. frontend 는 고유 키가 없어 헤더 주석만 동일하게 둠.
- **전역 `relay.css` 모달 규칙 상속 주의**: 공통 `.modal-body`(text-align:center)·`.mbtn`(flex:1)·`.modal-backdrop` 가 전역이라, 다른 레이아웃의 커스텀 모달(예: `.invite-modal`)은 **스코프 클래스로 `text-align`/`flex` 를 명시적으로 리셋**해야 한다. (초대 모달 본문 좌측정렬·푸터 버튼 폭 깨짐의 원인이었음)

---

## 6. 실행 / 검증

```bash
# 개발 환경 기동 (의존성 변경 시 --build -V)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build -V

# 백엔드 로그
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
```

- 백엔드: `npm run build` / `npm run lint` (현재 0 error)
- 프론트: `npm run type-check` / `npm run lint` / `npm run build-only` (현재 0 error)
- dev `synchronize=true` 가 `users`·`repos`·`repo_members`·`tasks`·`checklist_items`·`task_assignees` 테이블 자동 생성(Gitea 연동 컬럼 포함)·스키마 변경 자동 반영(예: `sub_role` 컬럼 드롭, `tasks.repo_id` FK 컬럼명). DB가 비어 있으므로 **회원가입 후 로그인**. 엔티티 신규 추가(4단계) 시 백엔드 컨테이너 재기동으로 스키마 반영.
- 포트: 프론트 5273, 백엔드 3100(`/api`), Swagger `http://localhost:3100/api-docs`.

### Gitea 연동 설정/검증

1. `backend/.env.{env}` 에 `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` 채우기(예시: `backend/.env.{env}.example`).
   - 토큰: Gitea → Settings → Applications → Generate Token(조직 repo 생성 권한 필요).
   - docker-compose 는 `backend/.env.{env}` 를 `env_file` 로 자동 주입하므로 compose 수정 불필요.
2. 부팅 로그에서 `Gitea 연동 활성화 — <url> (org: <org>)` 확인(미설정 시 비활성 경고).
3. Relay 에서 저장소 생성 → Gitea `<org>` 조직에 동일 slug repo 가 생기는지 확인. 응답 `cloneUrl`/`htmlUrl` 채워짐.
4. 수정(설명/공개범위)·삭제 시 Gitea 측 반영 확인.
5. (선택) `REPO_IMPORT_ADMIN_EMAIL` 에 **가입된** 사용자 이메일을 넣으면, 동기화 시 멤버 0명 import 저장소에 그 사용자가 owner-admin 으로 지정된다. **env 변경이므로 컨테이너 재생성 필요**(`up -d --force-recreate backend`). 부팅 로그의 `import 저장소 owner-admin 지정: <slug> ← <email>` 로 확인.
