first commit

This commit is contained in:
2026-06-16 17:12:08 +09:00
commit e1bc5a1dfc
257 changed files with 49823 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
# Relay API 계약서 (SSOT)
> 프론트(`frontend/src/mock/relay.mock.ts`) 인터페이스를 기준으로 도출한 **백엔드 ↔ 프론트 공통 계약**.
> 백엔드 엔티티/DTO와 프론트 타입은 이 문서를 단일 진실 공급원으로 삼아 양쪽이 동일하게 구현한다.
> 모든 응답은 루트 `CLAUDE.md` 의 표준 응답 래퍼(`{ success, data }` / `{ success, error }`)로 감싸지며,
> 아래 표의 "응답"은 **언래핑된 `data` 본문**만 기술한다.
## 0. 공통 규약
### 인증 / 토큰
- **이메일 + JWT만 1단계 구현.** 소셜 로그인(Google/Kakao)·이메일 인증은 8단계로 보류.
- Access / Refresh 토큰 **모두 HttpOnly·Secure 쿠키**로 전달 (웹 `localStorage` 금지).
- Access: 짧은 만료(15분), 쿠키명 `access_token`
- Refresh: 긴 만료(14일), 쿠키명 `refresh_token`, 경로 `/api/auth`
- 프론트 axios 는 `withCredentials: true` (이미 적용됨) — 별도 Authorization 헤더 불필요.
- 보호 라우트는 `JwtAuthGuard`. 미인증 시 401 → `AUTH_001`.
### ID 전략
| 대상 | 타입 | 비고 |
|---|---|---|
| User | `uuid` | 외부 노출 식별자 |
| Repo | `uuid` + `slug` | URL 은 `owner/name` slug, 라우팅 segment 는 `slug` 마지막 토큰 |
| Task | `number` | **저장소 내 순번**(GitHub 이슈처럼 `#9`). 전역 PK 는 별도 uuid |
| 그 외 | `uuid` | Comment, Attachment 등 |
### 파생 필드(저장하지 않고 계산)
다음은 **백엔드가 원시값(타임스탬프/카운트)만 내려주고 프론트 `shared/utils/format.ts` 에서 계산**한다.
- `updatedAgo`("2시간 전 업데이트"), `dueLabel`("D-3", "2일 지남", "6월 9일 완료"), `dday`, `ddayLevel`
- `progressPct`, `progressLevel`, `progressLabel`, `doneCount`/`totalCount`(서버 카운트 응답은 허용)
- 아바타 `initial`/`color` — 서버는 `name` 만, color 는 userId 해시로 프론트 산출(또는 서버 저장)
따라서 API 응답의 날짜 필드는 모두 **ISO 8601 문자열**(`dueDate`, `updatedAt`, `createdAt` 등)로 내린다.
### 비즈니스 에러 (사용자 노출 문구)
도메인 검증 실패처럼 **사용자에게 구체 문구를 보여줘야 하는 경우** `BusinessException(code, message, status)`
(`backend/src/common/exceptions/business.exception.ts`)를 던진다. 전역 필터가 `{ code, message }`
status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프론트는 `BIZ_001` 코드일 때 서버 `message` 를 그대로 노출한다.
예: 로그인 실패 → `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)`.
### 페이지네이션
목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`, 응답 `{ items: [...], total, page, size }`.
(1단계에서는 단순 배열 허용, 7단계에서 페이지네이션으로 통일)
---
## 1. 인증 (`/api/auth`) — **1단계**
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|---|---|---|---|---|
| POST | `/auth/signup` | `{ email, password, name }` | `{ user }` | 가입 후 자동 로그인(쿠키 set) |
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set |
| POST | `/auth/logout` | — | `null` | 쿠키 clear |
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | access 쿠키 재발급 |
| GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 |
`User` 응답 형태:
```jsonc
{
"id": "uuid",
"email": "sykim@acme.co",
"name": "김서연",
"role": "콘텐츠 기획" // 전사 직무(선택)
}
```
> 프론트 매핑: `auth.store.ts`(신규) + `useAuth` composable. 라우터 가드 TODO 를
> `me` 부트스트랩 + 401→`/login` 리다이렉트로 교체. `LoginPage`/`SignupPage` 의 `router.push` 를 실제 호출로.
---
## 2. 저장소 (`/api/repos`) — **2단계**
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — 내가 멤버인 저장소 |
| POST | `/repos` | `CreateRepoDto` | `Repo` |
| GET | `/repos/:repoId` | — | `Repo`(상세) |
| PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` |
| DELETE | `/repos/:repoId` | — | `null` |
`CreateRepoDto`: `{ name, desc, visibility('private'|'public'), branch? }`
`Repo` 응답(원시값 중심):
```jsonc
{
"id": "q3-launch-campaign", // slug 마지막 segment = 라우팅 id
"name": "3분기 신제품 런칭 캠페인",
"owner": "marketing-team",
"slug": "marketing-team/q3-launch-campaign",
"desc": "...",
"visibility": "private",
"branch": "main",
"members": [ /* User , N + memberCount */ ],
"memberCount": 5,
"doneCount": 8,
"totalCount": 12,
"updatedAt": "2026-06-16T11:00:00Z"
}
```
> `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산.
> 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage.
---
## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계**
| 메서드 | 경로 | 요청 | 응답 | 권한 |
|---|---|---|---|---|
| GET | `.../members` | — | `RepoMember[]` | member+ |
| POST | `.../members` | `{ email, roleType, subRole? }` | `RepoMember` | admin |
| PATCH | `.../members/:userId` | `{ roleType?, subRole? }` | `RepoMember` | admin |
| DELETE | `.../members/:userId` | — | `null` | admin (owner 제거 불가) |
`RepoMember`: `{ user: User, email, subRole, taskCount, roleType('admin'|'member'), owner }`
(`isYou` 는 프론트가 현재 사용자와 비교해 산출)
> 매핑 페이지: RepoMembersPage(초대 모달, 역할 변경, 제거).
---
## 4. 업무 (`/api/repos/:repoId/tasks`) — **4단계**
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` |
| POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` |
| GET | `.../tasks/:taskId` | — | `TaskDetail` |
| PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` |
| DELETE | `.../tasks/:taskId` | — | `null` |
| POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` |
상태(`TaskStatus`): `todo | prog | review | done | changes`. 전이 규칙(상태 머신):
```
todo ─시작→ prog ─승인요청→ review ─승인→ done
▲ └─수정요청→ changes ─재요청→ review
└──────────────────────────────────┘
```
- `POST .../status` 는 일반 상태 변경.
- **승인 워크플로우**(5단계와 함께): 담당자 "승인 요청"(prog→review), 지시자 "승인"(review→done) / "수정 요청"(review→changes).
`CreateTaskDto`: `{ title, content?: string[], assigneeIds: uuid[], dueDate?: ISO, checklist?: {text}[] }`
`RepoTask`(목록 행): `{ id, title, status, dueDate, checklist: [done,total], commentCount, assignees: User[] }`
`TaskDetail`: mock 의 `TaskDetail` 인터페이스와 동일 — 단 라벨류는 원시값으로:
`{ id, repoId, repoName, title, status, content[], checklist[], comments[], activities[], assignees[], issuer, dueDate, files[], approval?, createdAt }`
> 매핑 페이지: RepoDetailPage(목록), TaskCreatePage, TaskDetailPage(지시자), TaskAssigneePage(담당자, state 분기).
---
## 5. 업무 상세 부속 — **5단계**
### 체크리스트
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/checklist` | `{ text }` | `ChecklistItem` |
| PATCH | `.../checklist/:itemId` | `{ text?, done? }` | `ChecklistItem` |
| DELETE | `.../checklist/:itemId` | — | `null` |
### 댓글 / 답글
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/comments` | `{ text, fileId? }` | `Comment` |
| POST | `.../comments/:commentId/replies` | `{ text }` | `Reply` |
### 파일 첨부
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/files` | `multipart/form-data` | `Attachment` |
| DELETE | `.../files/:fileId` | — | `null` |
> **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO). `Attachment`: `{ id, name, size, kind('pdf'|'zip'|'doc'|'img'), url }`.
### 승인 워크플로우
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| POST | `.../tasks/:taskId/approval/request` | `{ note }` | `TaskDetail` |
| POST | `.../tasks/:taskId/approval/approve` | — | `TaskDetail` |
| POST | `.../tasks/:taskId/approval/changes` | `{ note }` | `TaskDetail` |
---
## 6. 활동 피드 — **6단계**
활동은 **2~5단계 행위에서 이벤트로 자동 적재**(쓰기 API 없음, 읽기만).
| 메서드 | 경로 | 응답 |
|---|---|---|
| GET | `/repos/:repoId/activity` | `RepoActivityDay[]`(날짜 그룹) |
| (업무 활동) | `TaskDetail.activities` 에 포함 | `Activity[]` |
`RepoActivityItem`: `{ tone, icon, actor: User, payload, createdAt }`
> 서버는 이벤트 타입 + 행위자 + 대상만 저장하고, **표시 HTML 문구는 프론트에서 조립**(현재 mock 은 html 통문자열 — 연동 시 구조화 권장). 매핑: RepoActivityPage, TaskDetail 활동 탭.
---
## 7. 내 업무 (`/api/tasks`) — **7단계**
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `/tasks/assigned` | — | `MyTaskGroup[]`(상태 그룹) — 내가 담당 |
| GET | `/tasks/issued` | — | `MyTaskGroup[]` — 내가 지시 |
저장소 횡단 집계. `MyTaskGroup`/`MyTaskRow` 는 mock 인터페이스와 동일하되 라벨류 제외, 원시값(`dueDate`, `status`) 기반.
> 매핑 페이지: MyTasksPage(담당/지시 탭, 상태 그룹). `countActive` 는 프론트 유지.
---
## 8. 마무리(선택) — **8단계**
소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일.
---
## 백엔드 모듈 매핑 (구현 순서)
```
src/modules/
auth/ (1) — User 엔티티, JWT 전략, Guard, 쿠키
repo/ (2) — Repo 엔티티
member/ (3) — RepoMember 조인 엔티티 (repo 모듈에 흡수 가능)
task/ (4,5) — Task / ChecklistItem / Comment / Reply / Attachment
activity/ (6) — Activity 이벤트 (다른 모듈에서 emit)
my-tasks/ (7) — 집계 전용 (task 모듈에 흡수 가능)
```
+128
View File
@@ -0,0 +1,128 @@
# Relay 백엔드 연동 진행 현황
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
> 최종 갱신: 2026-06-16 · 현재 위치: **2단계(저장소) 완료, 3단계(멤버) 대기**
---
## 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. 저장소 ✅
→ 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`, `subRole`, `@Unique([repo, user])`.
- `RepoService`: 내 멤버십 저장소 목록 / 단건(멤버·공개 접근 체크) / 생성(생성자를 owner·admin 등록) / 수정(admin) / 삭제(owner).
- `GET/POST/PATCH/DELETE /repos[/:repoId]` — 전부 `JwtAuthGuard`. 응답 `RepoResponse`(원시값; `doneCount/totalCount`=0, 4단계에서 집계).
**프론트**
- `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**(저장·삭제·이름변경 안내).
---
## 3. 남은 단계
### 3단계 — 멤버 관리
- 백엔드: `GET/POST/PATCH/DELETE /repos/:repoId/members`(목록·초대·역할변경·제거), admin 권한 가드, owner 제거 불가.
- 프론트: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동.
- `RepoMember` 엔티티는 2단계에서 이미 존재 → 데이터 계층은 일부 재사용.
### 4단계 — 업무(Task) CRUD + 상태 전이
- 백엔드: `Task`(+`ChecklistItem`) 엔티티, `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]`, `POST .../status`. 상태 머신 todo→prog→review→done/changes.
- 저장소 `doneCount/totalCount` 실제 집계로 교체(현재 0 하드코딩).
- 프론트: **RepoDetailPage 업무 목록**, **TaskCreatePage**, **TaskDetailPage**(지시자), **TaskAssigneePage**(담당자) 연동.
### 5단계 — 업무 상세 부속
- 체크리스트 토글, 댓글/답글, **파일 첨부**, 승인 워크플로우(승인 요청/승인/수정 요청).
- **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO).
### 6단계 — 활동 피드
- 2~5단계 행위를 이벤트로 적재(쓰기 API 없음, 읽기만). `GET /repos/:repoId/activity`.
- 프론트: **RepoActivityPage**, TaskDetail 활동 탭.
- **결정 필요**: 활동 표현 — 현재 mock은 HTML 통문자열 → 구조화(타입+행위자+대상) 권장.
### 7단계 — 내 업무 집계
- `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단 집계). 프론트: **MyTasksPage**(담당/지시 탭).
### 8단계 — 마무리(선택)
- 소셜 로그인(Google/Kakao), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일.
---
## 4. 전환기 한계 (현재 시점)
- **RepoMembersPage·RepoActivityPage** 는 아직 mock `findRepo` 사용 → 실제 생성한 저장소의 **멤버/활동 탭은 "찾을 수 없음"** 표시(각각 3·6단계에서 해소).
- **RepoDetail 업무 목록**도 mock(4단계). 업무 모듈이 없어 모든 저장소 **진행률은 `0% / 시작 전`**.
- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus` 등) + 미연동 화면 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전.
---
## 5. 알아둘 사항 / 결정 기록
- **엔티티 순환참조**: 서로 참조하는 관계(예: `Repo``RepoMember`)의 **단일 클래스 관계 속성에는 TypeORM `Relation<>` 래퍼**를 사용한다. 안 그러면 SWC 환경에서 `Cannot access 'X' before initialization` 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용.
- **bcrypt → bcryptjs**: Docker 베이스가 `node:20-alpine`(빌드 도구 없음)이라 네이티브 모듈 `bcrypt` 대신 순수 JS `bcryptjs` 사용.
- **dev 도커 익명 볼륨**: `/app/node_modules` 가 익명 볼륨이라 **의존성 변경 시 `up -d --build -V`(익명 볼륨 갱신)** 필요. `--build` 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음.
- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. 조직 도입 시 교체.
- **ID 전략**: User/Repo PK=UUID, Repo 라우팅 식별자=`slugName`, Task=저장소 내 순번(#9, 4단계).
---
## 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` 테이블 자동 생성. DB가 비어 있으므로 **회원가입 후 로그인**.
- 포트: 프론트 5273, 백엔드 3100(`/api`), Swagger `http://localhost:3100/api-docs`.