# 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[]`(요약) — **조직 저장소 전체**(생성 출처 무관) | | GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) | | POST | `/repos` | `CreateRepoDto` | `Repo` | | GET | `/repos/:repoId` | — | `Repo`(상세) | | PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` | | DELETE | `/repos/:repoId` | — | `null` | `GET /repos/meta` 응답: `{ owner: string, template: { available: boolean, slug: string } }` — 생성 전 소유자(조직) 고정 표시 + 템플릿 옵션 노출용. `CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description, branch?, useTemplate? }` (**description 필수**, `useTemplate=true` 면 템플릿 저장소를 복제해 생성) `Repo` 응답(원시값 중심): ```jsonc { "id": "q3-launch-campaign", // slug 마지막 segment = 라우팅 id "name": "3분기 신제품 런칭 캠페인", "owner": "marketing-team", // Gitea 연동 시 Gitea 조직명 "slug": "marketing-team/q3-launch-campaign", // = Gitea full_name "desc": "...", "visibility": "private", "branch": "main", "members": [ /* User 요약 배열, 최대 N명 + memberCount */ ], "memberCount": 5, "doneCount": 8, "totalCount": 12, "cloneUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign.git", // 미연동 시 null "htmlUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign", // 미연동 시 null "giteaMissing": false, // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신) "updatedAt": "2026-06-16T11:00:00Z" } ``` > `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산. > `GET /repos` 는 단일 조직 모델상 **조직의 모든 저장소**를 반환한다(멤버십은 역할/권한에만 사용). Gitea-동기화로 import 된 저장소는 멤버 0명으로 노출되며, `giteaMissing:true` 면 프론트에서 '원격 없음' 배지로 표기한다. > 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage. #### Gitea 연동 (외부 인스턴스) 저장소 생명주기를 외부 Gitea 조직에 동기화한다. `GITEA_BASE_URL`/`GITEA_TOKEN`/`GITEA_ORG` (backend `.env`) 가 모두 설정되면 활성화되고, 비어 있으면 Gitea 호출 없이 기존대로 동작한다. | Relay 동작 | Gitea API | 매핑 | |---|---|---| | `POST /repos` (빈 저장소) | `POST /api/v1/orgs/{org}/repos` (auto_init) | `slug`→repo name, `visibility`→private, `branch`→default_branch, `name`+`desc`→description | | `POST /repos` (`useTemplate`) | `POST /api/v1/repos/{tplOwner}/{tplRepo}/generate` (git_content) | `owner`→org, `slug`→name, `name`+`desc`→description, `visibility`→private. 기본 브랜치는 템플릿을 따름 | | `PATCH /repos/:id` | `PATCH /api/v1/repos/{org}/{slug}` | `name`/`desc` 변경→description, `visibility` 변경→private (slug 불변) | | `DELETE /repos/:id` | `DELETE /api/v1/repos/{org}/{slug}` | — | - 템플릿 출처: `GITEA_TEMPLATE_OWNER`/`GITEA_TEMPLATE_REPO`(기본 `TtiPo`/`project-base`). 해당 저장소는 Gitea 에서 *template* 로 설정돼 있어야 한다. - 인증: 조직 권한 토큰 단일 사용(`Authorization: token `). 사용자별 Gitea 계정 매핑은 추후. - 생성은 Gitea 우선 → 성공 시 Relay 저장(연동 정보 `giteaRepoId`/`cloneUrl`/`htmlUrl` 보관). Relay 저장 실패 시 Gitea repo 롤백 삭제. - Gitea 이름 충돌(409) → `BIZ_001` "이미 사용 중인 저장소 이름입니다.", 그 외 실패 → `BIZ_001`(502). - 수정/삭제의 Gitea 동기화는 **베스트 에포트**(실패해도 Relay 상태는 반영, 에러 로그만 남김). ##### Gitea → Relay 역방향 동기화 (주기적 import) 조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 매시간 정각**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`). - **추가형(additive)**: Gitea ID → slug 순으로 매칭. Relay 에 없으면 import(멤버 0명), 양쪽에 있으면 연동정보(`giteaRepoId`/`cloneUrl`/`htmlUrl`)만 backfill. 표시명/설명/공개범위는 import 이후 **Relay 가 SSOT**(재동기화로 덮어쓰지 않음). - **import 표시명**: Gitea description 의 `"표시제목 — 설명"` 규칙을 파싱(구분자 없으면 slug 를 표시명, 전체를 설명으로). - **누락 표기**: Gitea 에서 사라진(연동 origin) 저장소는 **삭제하지 않고** `giteaMissing:true` 로만 표기. 다시 나타나면 false 로 복구. - Gitea 미연동(`GITEA_*` 미설정) 시 동기화는 건너뛴다. 중복 실행 방지 가드 포함. --- ## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계** | 메서드 | 경로 | 요청 | 응답 | 권한 | |---|---|---|---|---| | GET | `.../members` | — | `RepoMember[]` | 인증(조직 모델상 누구나 열람) | | POST | `.../members` | `{ email, roleType }` | `RepoMember` | admin | | PATCH | `.../members/:userId` | `{ roleType? }` | `RepoMember` | admin | | DELETE | `.../members/:userId` | — | `null` | admin (owner 제거 불가) | `RepoMember`: `{ user: User, email, taskCount, roleType('admin'|'member'), owner }` (`isYou` 는 프론트가 현재 사용자와 비교해 산출. `taskCount` 는 4단계에서 사용자별 담당 업무 수로 실집계) > **가시성 정책**: 멤버십은 **쓰기/권한과 업무 담당 자격**에만 쓰이고, 저장소·멤버·업무의 **조회는 인증 사용자면 누구나 가능**(단일 조직 모델). 비멤버를 가리는 가시성 가드는 도입하지 않음. > 매핑 페이지: RepoMembersPage(초대 모달, 역할 변경, 제거). #### 사용자 검색 (`/api/users`) — 멤버 초대 자동완성 | 메서드 | 경로 | 요청 | 응답 | 권한 | |---|---|---|---|---| | GET | `/users` | `?q=<검색어>` | `User[]`(PublicUser) | 인증 | - `q` 있으면 **이메일/이름 부분일치(대소문자 무시)**, 없으면 전체 목록(이름순, 최대 50). 응답은 `PublicUser`(`{ id, email, name, role }`). - 초대 모달의 이메일 입력에서 호출: 포커스 시 전체, 입력 시 부분일치 목록을 띄우고 선택하면 이메일이 채워진다. **이미 저장소 멤버인 사용자는 프론트가 목록에서 제외**(현재 멤버 목록과 id 비교). --- ## 4. 업무 (`/api/repos/:repoId/tasks`) — **4단계 ✅ 구현 완료** | 메서드 | 경로 | 요청 | 응답 | 권한 | |---|---|---|---|---| | GET | `.../tasks` | `?status&assignee` | `RepoTask[]` | 인증 | | POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` | admin | | GET | `.../tasks/:taskId` | — | `TaskDetail` | 인증 | | PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` | admin | | DELETE | `.../tasks/:taskId` | — | `null` | admin | | POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` | 멤버(전이별 역할 검증) | - `:taskId` 는 **저장소 내 순번(seq)** — 정수(`ParseIntPipe`). 전역 PK 는 별도 uuid. - 목록 정렬: seq 내림차순(최신 우선). `?status`/`?assignee`(userId) 필터. 상태(`TaskStatus`): `todo | prog | review | done | changes`. 전이 규칙(상태 머신): ``` todo ─시작→ prog ─승인요청→ review ─승인→ done ▲ └─수정요청→ changes ─재요청→ review └──────────(changes→prog)───────────┘ ``` 전이 허용 맵: `todo→[prog]`, `prog→[review]`, `review→[done,changes]`, `changes→[review,prog]`, `done→[]`. 허용 외 전이는 `BIZ_001`(409). **역할 검증**(`POST .../status`): - 승인/수정요청(**review 출발**: review→done, review→changes) = **지시자(issuer) 또는 admin**. - 그 외 작업 전이(시작 todo→prog, 승인요청 prog→review, 재요청 changes→review/prog) = **담당자(assignee) 또는 지시자/admin**. - 비멤버는 전이 불가(403). `from === next` 면 no-op(현재 상태 반환). **부수효과**: - **담당자 지정은 저장소 멤버만 가능** — `assigneeIds` 에 비멤버 포함 시 `BIZ_001`(400, "담당자는 저장소 멤버만 지정할 수 있습니다."). - **승인 완료(→done) 시 체크리스트 항목 done 을 모두 true 로 영속 처리**(승인=완료의 부수효과). - 생성 시 status 는 항상 `todo`. 저장소 `doneCount/totalCount` 는 업무 status 집계(`done` 수/전체)로 실시간 반영, 멤버 `taskCount` 는 사용자별 담당 업무 수로 집계. `CreateTaskDto`: `{ title, content?: string[], assigneeIds: uuid[](≥1), dueDate?: ISO, checklist?: {text}[] }` `UpdateTaskDto`: `{ title?, content?: string[], assigneeIds?: uuid[], dueDate?: ISO|null }` — 제공된 필드만 갱신(체크리스트 항목 편집은 5단계). `RepoTask`(목록 행): `{ id(seq), title, status, dueDate: ISO|null, checklist: [done,total], commentCount, assignees: User[] }` `TaskDetail`: `{ id(seq), repoId, repoName, title, status, content[], checklist: {id,text,done}[], assignees[], issuer: User|null, dueDate: ISO|null, createdAt, comments[], activities[], files[] }` > **comments/activities/files 는 5·6단계 영역** — 4단계 응답에서는 빈 배열로 내려간다. `approval` 객체도 5단계(현재 미포함). > **프론트 매핑/동작**: > - 목록 진입은 항상 `…/tasks/:taskId`(**TaskDetailPage**) — 이 화면이 **현재 사용자 역할로 액션을 분기**한다(지시자: 승인/수정요청, 담당자: 업무 시작/승인 요청/재요청·승인 대기 중). `isIssuer`/`isAssignee` 로 판별. > - **수정**: 생성 폼(TaskCreatePage)을 편집 모드로 재사용 — 프론트 라우트 `…/tasks/:taskId/edit`(API 는 동일 PATCH). 기존 값 미리 채움, 체크리스트는 읽기 전용. > - 라벨류(dueLabel/dday/statusLabel)는 `format.ts`(`taskStatusLabel`/`taskDueInfo`)에서 원시값으로 파생. > - `TaskAssigneePage`(`/view`)는 mock '내 업무' 전용 잔존(7단계 정리). 댓글/AI 패널/첨부 업로드는 로컬 데모(5단계 실연동). --- ## 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 흡수, Gitea 동기화) member/ (3) ✅ — RepoMember 조인 엔티티 (repo 모듈에 흡수됨) task/ (4) ✅ — Task / ChecklistItem (Comment / Reply / Attachment 는 5단계) activity/ (6) — Activity 이벤트 (다른 모듈에서 emit) my-tasks/ (7) — 집계 전용 (task 모듈에 흡수 가능) ``` > 실제 배치: `member` 는 `repo` 모듈에 흡수(`MemberService`/`MemberController` + `RepoMember` 재사용). `task` 모듈은 4단계 완료(엔티티·CRUD·상태머신·집계), 5단계에서 Comment/Reply/Attachment 추가 예정. `RepoModule` 이 진행률·taskCount 집계를 위해 `TaskModule` 을 import(엔티티만 참조해 순환 없음).