# 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)`. ### 페이지네이션 — **8단계에서 통일(완료)** 목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`(기본 size 20, 최대 100), 응답 `{ items: [...], total, page, size }`. 프론트는 **더보기 버튼**으로 점진 로드(시안에 페이지 UI 없음). - **백엔드 적용 완료**: `GET /repos`, `/repos/:id/members`, `/tasks/assigned`, `/tasks/issued`, `/repos/:id/tasks`(세그먼트/검색 + `counts` 동봉). 공통 유틸 `common/pagination`. - **프론트 적용 완료(전 도메인)**: - **더보기(오프셋)**: 저장소 목록, 내 업무, **저장소 업무 목록(RepoDetailPage)**. 저장소 업무는 세그먼트(`segment`)·검색(`q`)도 백엔드로 이전하고 세그먼트 배지 수는 응답 `counts` 사용(`TaskListQuery={segment,q,assignee}`, `ApiRepoTaskListResult=Paginated & {counts}`). - **전체 로드**: **멤버(RepoMembersPage)** 는 `member.store.fetchList` 가 페이지네이션 API를 끝까지 순회해 전체를 적재(더보기 대신). 역할/검색 카운트가 전수 기준이어야 하고, TaskCreatePage 담당자 후보 풀이 같은 호출을 쓰므로 전체 셋을 보장한다(멤버는 팀 규모로 한정적). - (회귀 수정 `c128b19`: `useMember.list`·`useTask.list` 가 `Paginated` 를 반환하도록 정합 — 위 작업의 선행 단계로 흡수됨.) --- ## 1. 인증 (`/api/auth`) — **1단계** | 메서드 | 경로 | 요청 | 응답 | 비고 | |---|---|---|---|---| | POST | `/auth/signup` | `{ email, password, name }` | `{ email, verificationPending }` | **자동 로그인 X** — 인증 메일 발송, 쿠키 미발급 | | POST | `/auth/resend-verification` | `{ email }` | `null` | 인증 메일 재발송(존재 여부 비노출) | | GET | `/auth/verify-email` | `?token` | 302 redirect | 메일 링크. 검증 후 `FRONTEND_URL/login?verified=1\|0` 로 | | POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set. 미인증/소셜전용 계정은 차단 | | GET | `/auth/google` · `/auth/kakao` | — | 302 redirect | 소셜 동의 화면으로(전체 페이지 이동) | | GET | `/auth/google/callback` · `/auth/kakao/callback` | — | 302 redirect | 쿠키 set 후 `FRONTEND_URL/repos` 로 | | POST | `/auth/logout` | — | `null` | 쿠키 clear | | POST | `/auth/refresh` | — (refresh 쿠키) | `null` | **회전형** — access/refresh 모두 교체. 재사용/만료/누락 시 401(쿠키 제거) | | GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 | `User` 응답 형태: ```jsonc { "id": "uuid", "email": "sykim@acme.co", "name": "김서연", "role": "콘텐츠 기획", // 전사 직무(선택) "avatarUrl": null // 프로필 이미지 URL (소셜 가입 시 제공자 이미지) } ``` ### 회원가입 · 이메일 인증 · 소셜 로그인 흐름 - **이메일 가입**: `signup` → 미인증 계정 생성 + 인증 메일 발송(현재는 **백엔드 로그로 링크 출력** — `MailService` 플레이스홀더). 사용자가 메일의 `verify-email` 링크 클릭 → 인증 완료 → `/login?verified=1` 로 리다이렉트. **인증 전에는 로그인 차단**(`BIZ_001` 403). - **토큰**: 원문 토큰은 메일 링크에만, DB엔 SHA-256 해시 + 만료(24h)만 저장. 재발송은 사용자 존재/상태를 노출하지 않는다. - **소셜 로그인**(Google/Kakao): 프론트가 `/auth/{provider}` 로 전체 페이지 이동 → 콜백에서 이메일로 계정 매칭(없으면 생성, `emailVerified=true`) → 쿠키 발급 후 `/repos` 로. 키(`*_CLIENT_ID`) 미설정 시 해당 제공자 비활성. - **계정 모델**: `provider`('local'|'google'|'kakao'), `emailVerified`. 소셜 전용 계정은 비밀번호가 없어(`passwordHash=null`) 비밀번호 로그인 시 소셜 안내. 인증 도입 이전 로컬 계정은 부팅 시 그랜드페더링(토큰 없는 로컬 계정 → 인증 완료). - **세션/토큰 회전**: refresh 는 **회전형 + 재사용 탐지**. `refresh_sessions`(패밀리 단위, 한 로그인=1행) 에 현재 jti 를 두고, `/auth/refresh` 마다 새 jti 로 회전. 이미 회전된 옛 토큰이 다시 오면 탈취로 간주해 패밀리 폐기(전 세션 강제 로그아웃). 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight(`useApi`)로 방지. logout=해당 패밀리만 폐기. (access 토큰은 15분 만료까지 유효 — 즉시 폐기 denylist 미적용.) - **환경변수**: `APP_API_URL`(메일 링크용 백엔드 주소), `FRONTEND_URL`(리다이렉트 대상), `GOOGLE_*`/`KAKAO_*`(OAuth 키·콜백). `.env.development.example` 참조. --- ## 2. 저장소 (`/api/repos`) — **2단계** | 메서드 | 경로 | 요청 | 응답 | |---|---|---|---| | GET | `/repos` | `?page&size` | `Repo[]`(요약) — **조직 저장소 전체**(생성 출처 무관) | | GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) | | GET | `/repos/name-available` | `?slug` | `{ available: boolean }` — 이름(slug) 중복 체크(`: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 } }` — 생성 전 소유자(조직) 고정 표시 + 템플릿 옵션 노출용. `GET /repos/name-available?slug=` 응답: `{ available }` — 생성 폼의 **실시간 이름 가용성 체크**용(Relay slug 중복만 검사). 프론트는 형식(`^[a-z0-9-_]+$`)을 즉시 검사하고, 통과 시 디바운스로 이 API 를 호출한다. Gitea 이름 충돌은 생성(`POST /repos`) 시 최종 확인(`BIZ_001`). `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", "canManage": true, // 현재 사용자 admin 여부 — 단건(GET /repos/:id)에서만. 설정 탭 노출/진입·수정 권한 "isOwner": true // 현재 사용자 소유자 여부 — 저장소 삭제 권한 } ``` > `canManage`/`isOwner` 는 **단건 조회·수정 응답에만** 포함된다(목록 `GET /repos` 는 캐시·사용자 무관이라 미포함). 프론트는 이 값으로 **설정 탭을 admin 에게만 노출**하고, 비admin 의 설정 화면 직접 진입을 저장소 상세로 리다이렉트한다. 삭제 버튼은 `isOwner` 일 때만 활성. > `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회 + 10분마다**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`). - **추가형 + 역동기화(reconcile)**: Gitea ID → slug 순으로 매칭(이번 동기화에서 이미 매칭된 Relay 저장소는 중복 매칭 제외 — rename 후 옛 이름 재사용 충돌 방지). Relay 에 없으면 import(멤버 0명). 매칭되면 **Gitea 의 네이티브 필드와 1:1 대응되는 값을 Gitea 기준(SSOT)으로 끌어온다**: `slugName`(Gitea rename → Relay 라우팅·쓰기 경로가 새 이름을 따름), `visibility`(private 토글), `branch`(기본 브랜치), + 연동정보(`giteaRepoId`/`giteaFullName`/`cloneUrl`/`htmlUrl`, 누락표시 해제). **단 표시명(`name`)·설명(`description`)은 Gitea 단일 `description` 에 "표시제목 — 설명" 으로 합쳐 저장하므로(표시명 필드 부재), Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지**(역동기화 제외). Relay→Gitea 쓰기(`PATCH`)는 설명/공개범위에 더해 **기본 브랜치(`default_branch`)도 푸시**해 양방향을 일치시킨다(브랜치는 Gitea 에 실제 존재해야 함). - **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` | `?segment&q&assignee&page&size` | `{ items: RepoTask[], total, page, size, counts }` | 인증 | | 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 내림차순(최신 우선). `?segment`(all/prog/todo/done, prog=prog+review)·`?q`(제목 부분일치)·`?assignee`(userId) 필터 + `?page&size` 페이지네이션. 응답에 세그먼트 카운트(`counts{all,prog,todo,done}`, 검색어 반영) 동봉. 상태(`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, checklist?: {id?, text}[] }` — 제공된 필드만 갱신. `checklist` 제공 시 **전체 동기화**(id 있으면 기존 유지·완료 보존, 없으면 신규, 빠진 기존 항목은 삭제). `RepoTask`(목록 행): `{ id(seq), title, status, dueDate: ISO|null, checklist: [done,total], commentCount, fileCount, assignees: User[] }` — 목록 행은 댓글 수(말풍선)·첨부 수(클립)를 **분리 표기**(각 0이면 숨김). `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단계 ✅ 구현 완료** > 모든 엔드포인트 `JwtAuthGuard` + 저장소 멤버. `:taskId`=seq(정수), `:itemId`/`:fileId`/`:commentId`=uuid. > 파일 저장은 **서버 로컬 업로드 폴더**(`UPLOAD_DIR`, 기본 `cwd/uploads`) 채택. 승인 워크플로우는 4단계 상태머신(`POST .../status`)을 노트와 함께 확장한 형태. ### 체크리스트 — **상세 화면 읽기 전용 / 정의는 생성·수정 폼에서** - **완료 토글은 사용자가 하지 않는다** — 상세 화면에서는 표시만 하고, 완료는 승인 워크플로우에서 일괄 처리된다("승인 완료(→done) 시 체크리스트 항목 done 일괄 true", 4단계 부수효과). - **항목 추가/삭제(정의)는 업무 생성·수정 폼에서** 한다: 생성=`CreateTaskDto.checklist`, 수정=`UpdateTaskDto.checklist`(id 기준 전체 동기화). - (5단계 초기 설계의 `POST/PATCH/DELETE .../checklist` 항목별 토글 API 는 제거됨 — 항목 편집은 업무 수정 PATCH 로 통합.) ### 댓글 / 답글 | 메서드 | 경로 | 요청 | 응답 | |---|---|---|---| | 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` | > **결정됨**: 파일 저장소 = **서버 로컬 업로드 폴더**(S3/MinIO 후순위). `Attachment`: `{ id, name, size(바이트), kind('pdf'|'zip'|'doc'|'img'), url(다운로드 경로), uploader: User|null, createdAt }`. 업로드 필드명 `file`. 다운로드는 `GET .../files/:fileId/download`(인증 쿠키로 접근, 인라인 스트리밍). 크기 표기는 프론트 `formatBytes`. ### 승인 워크플로우 | 메서드 | 경로 | 요청 | 응답 | |---|---|---|---| | 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` | `ApiActivity[]`(최신순, 최대 200) — 날짜 그룹핑은 프론트 | | (업무 활동) | `TaskDetail.activities` 에 포함 | `ApiActivity[]`(해당 업무 seq) | `ApiActivity`(원시): `{ id, type, actor: User|null, taskSeq: number|null, payload: object, createdAt }` - `type`: 저장소 `repo.created`/`repo.renamed`/`repo.visibility_changed`, 멤버 `member.invited`/`member.role_changed`/`member.removed`, 업무 `task.created`/`task.status_changed`/`task.comment_added`(답글 포함)/`task.file_attached`/`task.assignees_changed`/`task.due_changed`/`task.edited`(제목·내용·체크리스트)/`task.file_removed`/`task.removed`. - `payload`(타입별): `taskTitle`, `statusFrom`/`statusTo`, `targetName`, `roleType`, `newName`, `visibility`, `fileName`, `assigneeNames`, `dueDate` 등. > 업무 수정(`update`)은 **실제로 바뀐 측면만** 골라 적재한다(담당자 변경=`assignees_changed`, 마감 변경=`due_changed`, 제목·내용·체크리스트 변경=`edited`). 답글은 댓글과 동일 `task.comment_added`. 저장소 삭제는 활동도 함께 사라져 미적재, 저장소 설명/브랜치 변경·체크리스트 개별 완료는 미적재(승인 완료로 일괄 표현). > **서버는 이벤트 타입 + 행위자 + 대상(payload)만 저장**하고, 표시 문구/아이콘/톤·날짜 그룹은 **프론트가 type+payload 로 조립**(구조화 채택). 사용자 입력은 `escapeHtml` 후 신뢰 태그만 삽입(v-html XSS 방지). 적재는 베스트 에포트(실패해도 본 기능 안 막음), 6단계 도입 이후 발생분만 기록(소급 없음). 매핑: RepoActivityPage(저장소 활동), TaskDetail 활동 탭(업무 활동). --- ## 7. 내 업무 (`/api/tasks`) — **7단계 ✅ 구현 완료** | 메서드 | 경로 | 요청 | 응답 | |---|---|---|---| | GET | `/tasks/assigned` | — | `MyTaskRow[]` — 내가 담당(저장소 횡단) | | GET | `/tasks/issued` | — | `MyTaskRow[]` — 내가 지시(저장소 횡단) | - 백엔드는 **플랫 행 배열**(`MyTaskRowResponse[]`)을 마감 임박순(마감 없음 뒤로)으로 반환하고, **상태 그룹핑·순서·라벨은 프론트가 파생**(활동 피드와 동일 패턴). - `MyTaskRowResponse`: `{ id(seq), repoId(slug), repoName, title, status, dueDate, checklist:[done,total], assignees: User[], issuer: User|null }`. - 프론트 파생: 담당 뷰=지시자 표시·그룹순서 `[changes,prog,todo,review,done]`, 지시 뷰=담당자 미니아바타·승인대기 강조(`내 승인 필요`)·그룹순서 `[review,prog,changes,todo,done]`. `dueLabel`/`dday`/`ddayLevel`/`overdue` 는 `taskDueInfo` 로 파생. > 매핑 페이지: MyTasksPage(담당/지시 탭, 상태 그룹). 행 클릭은 항상 `…/tasks/:taskId`(TaskDetailPage, 역할 기반 분기). **`TaskAssigneePage`(`/view`) 라우트·파일 제거 완료.** --- ## 8. 마무리(선택) — **8단계** ### 알림 (`/api/notifications`) — **✅ 구현 완료 (인앱 + 실시간 SSE)** 활동 피드와 별개로 **사용자별 수신함**을 두고, 도메인 행위에서 대상자에게 알림을 적재한다(쓰기 API 없음 — 다른 서비스가 내부에서 `NotificationService.notify` 호출). 실시간은 **SSE**(서버→클라이언트 단방향, 쿠키 인증). | 메서드 | 경로 | 요청 | 응답 | 비고 | |---|---|---|---|---| | GET | `/notifications` | `?page&size` | `{ items: ApiNotification[], total, page, size, unreadCount }` | 본인 수신함(최신순) | | POST | `/notifications/:id/read` | — | `{ unreadCount }` | 단건 읽음 | | POST | `/notifications/read-all` | — | `{ unreadCount: 0 }` | 전체 읽음 | | SSE | `/notifications/stream` | — (EventSource) | `ApiNotification` 스트림 | 실시간. 표준 래퍼 미적용(`@SkipTransform`), 25초 하트비트(`event: ping`) | `ApiNotification`(원시): `{ id, type, payload, read, createdAt }` - `type`(12종): `task.assigned`(지시→담당자) / `task.unassigned`(담당 해제→제거된 담당자) / `task.approval_requested`(승인요청→지시자) / `task.approved`(승인→담당자) / `task.changes_requested`(수정요청→담당자) / `task.started`(시작·재개→지시자) / `task.due_changed`(마감 변경→담당자, payload `dueDate`) / `task.removed`(삭제→관련자, 이동=저장소) / `task.commented`(댓글·답글→관련자, **답글은 원댓글 작성자 포함** + payload `isReply`) / `member.invited`(초대→피초대자) / `member.role_changed`(역할 변경→당사자) / `member.removed`(제거→당사자). - `payload`: `repoId`(slug)·`repoName`·`taskSeq`·`taskTitle`·`actorName`·`roleType` 등. 표시 문구/톤/이동경로(`/repos/:repoId/tasks/:taskSeq` 또는 `/repos/:repoId`)는 **프론트가 type+payload 로 조립**(사용자 입력은 escapeHtml). - **행위자 본인은 수신 대상에서 자동 제외.** 적재는 베스트 에포트(실패해도 본 흐름 안 막음), 도입 이후 발생분만(소급 없음). - 인증: 모든 엔드포인트 `JwtAuthGuard`(본인 알림 전용). SSE 도 `access_token` 쿠키(EventSource `withCredentials`)로 연결 시점 검증. - 실시간 전송은 `REDIS_HOST` 설정 시 **Redis pub/sub**(ioredis, 채널 `relay:notifications`)로 fan-out → **다중 인스턴스 수평 확장에서도 실시간 도달**. 미설정 시 인메모리 폴백(단일 인스턴스). > 매핑: AppShell 상단바 종(미읽음 뱃지 + 드롭다운). 연결은 store 싱글톤(EventSource)이 보유 — 화면 전환에 끊기지 않고 로그아웃 시 해제. ### 캐싱 (Redis) — **✅ 완료** 전역 CacheModule 을 Redis(keyv)로 전환(`REDIS_*` 미설정 시 인메모리 폴백, 기본 TTL 60초). **`GET /repos`(저장소 목록)**을 버전 네임스페이스로 캐시 — 키 `repos:list:v{ver}:p{page}:s{size}`, **저장소 생성/수정/삭제·Gitea 동기화·멤버 초대/제거** 시 버전 증가로 일괄 무효화(멤버 역할 변경은 목록 응답 불변이라 제외). 버전 카운터가 공유 Redis 에 있어 **무효화가 다중 인스턴스에서도 정합**. 업무 카운트(`doneCount/totalCount`)는 ≤60초 지연 허용. 응답 계약 불변(캐시는 투명) — 프론트 영향 없음. `GET /repos/meta` 는 DB 미접근 인메모리 getter 라 캐싱 제외. ### 소셜 로그인 · 회원가입 · 이메일 인증 — **✅ 완료** Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조. ### AI 채팅 (`/api/ai/conversations`) — **✅ 완료 (DB 영속, 다중 대화)** | 메서드 | 경로 | 요청 | 응답 | 비고 | |---|---|---|---|---| | GET | `/ai/conversations` | `?page&size` | `{items:[{id,title,updatedAt}],total,...}` | 내 대화 목록(최근순) | | GET | `/ai/conversations/:id` | — | `{id,title,messages:[{role,content}]}` | 대화 상세(본인 소유만) | | POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 | | POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 | | DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) | | POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 | - **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기). - `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지). - **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트). - 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크). - **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵). ### 그 외(미착수) 메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전·AI 채팅은 완료.) --- ## 백엔드 모듈 매핑 (구현 순서) ``` 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 모듈에 흡수 가능) notification/ (8) ✅ — Notification 엔티티 + SSE 스트림 (task/repo 모듈에서 notify) ``` > 실제 배치: `member` 는 `repo` 모듈에 흡수(`MemberService`/`MemberController` + `RepoMember` 재사용). `task` 모듈은 4단계 완료(엔티티·CRUD·상태머신·집계), 5단계에서 Comment/Reply/Attachment 추가 예정. `RepoModule` 이 진행률·taskCount 집계를 위해 `TaskModule` 을 import(엔티티만 참조해 순환 없음).