Files
comrelay/docs/api-contract.md
T
ttipo 9bcbc1564f revert: AI 코드 검토 기능 제거 (85ae82f 되돌림)
통짜 코드 전송 방식이 토큰 비효율·부정확해, 검색 기반(grep으로 좁힌 뒤 관련
스니펫만 판정) 방식으로 재설계하기 위해 일괄 제거. 추후 개선안으로 재작업 예정.

This reverts commit 85ae82f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:56:43 +09:00

33 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
  • 프론트 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<ApiRepoTask> & {counts}).
    • 전체 로드: 멤버(RepoMembersPage)member.store.fetchList 가 페이지네이션 API를 끝까지 순회해 전체를 적재(더보기 대신). 역할/검색 카운트가 전수 기준이어야 하고, TaskCreatePage 담당자 후보 풀이 같은 호출을 쓰므로 전체 셋을 보장한다(멤버는 팀 규모로 한정적).
  • (회귀 수정 c128b19: useMember.list·useTask.listPaginated<T> 를 반환하도록 정합 — 위 작업의 선행 단계로 흡수됨.)

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 응답 형태:

{
  "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 응답(원시값 중심):

{
  "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 <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/overduetaskDueInfo 로 파생.

매핑 페이지: 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)

실제 배치: memberrepo 모듈에 흡수(MemberService/MemberController + RepoMember 재사용). task 모듈은 4단계 완료(엔티티·CRUD·상태머신·집계), 5단계에서 Comment/Reply/Attachment 추가 예정. RepoModule 이 진행률·taskCount 집계를 위해 TaskModule 을 import(엔티티만 참조해 순환 없음).