백엔드
- Task/ChecklistItem 엔티티(저장소 내 순번 seq, jsonb content, assignees ManyToMany, checklist)
- GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId] + POST .../status
- 상태 머신(todo→prog→review→done, review→changes, changes→{review,prog}) + 전이별 역할 검증
- 담당자=저장소 멤버만 지정, 승인 완료(→done) 시 체크리스트 일괄 완료(영속)
- 저장소 doneCount/totalCount·멤버 taskCount 실집계(RepoModule→TaskModule)
- Task.repo FK 컬럼명 snake_case 고정(@JoinColumn), task.service 단위 테스트
프론트
- types/task·useTask·task.store, format.ts 라벨 파생(상태/마감/dday)
- RepoDetailPage 업무 목록, TaskCreatePage 생성/수정 겸용(편집 라우트)
- TaskDetailPage 역할 기반 액션 분기(지시자 승인/수정요청, 담당자 시작/승인요청)
- 액션 카드·상태 뱃지 디자인 시안 정렬, 작성폼/목록 UI 정리
문서: api-contract·integration-progress 4단계 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17 KiB
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
- Access: 짧은 만료(15분), 쿠키명
- 프론트 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,ddayLevelprogressPct,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 응답 형태:
{
"id": "uuid",
"email": "sykim@acme.co",
"name": "김서연",
"role": "콘텐츠 기획" // 전사 직무(선택)
}
프론트 매핑:
auth.store.ts(신규) +useAuthcomposable. 라우터 가드 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 응답(원시값 중심):
{
"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 <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(엔티티만 참조해 순환 없음).