From a3f6ecb5c4c6c0799dab4f0d24a76fad3c1b1505 Mon Sep 17 00:00:00 2001 From: Documentation Bot Date: Mon, 10 Nov 2025 01:43:07 +0000 Subject: [PATCH] docs: Sync documentation from private repository --- API_SPEC.md | 1364 ++++++++++++++++++++++++++++++++++++++++++ DATA_MODELS.md | 772 ++++++++++++++++++++++++ PROJECT_STRUCTURE.md | 8 +- ROADMAP.md | 6 +- TECH_STACK.md | 27 +- 5 files changed, 2164 insertions(+), 13 deletions(-) create mode 100644 API_SPEC.md create mode 100644 DATA_MODELS.md diff --git a/API_SPEC.md b/API_SPEC.md new file mode 100644 index 0000000..07ee3c8 --- /dev/null +++ b/API_SPEC.md @@ -0,0 +1,1364 @@ +# API Specification + +라온누리 서버 API 명세서 + +## ⚠️ 최신 변경사항 (2025-11-10) + +### 🔐 5단계 보안 레벨 시스템 +팀 생성 시 5가지 보안 레벨 선택 가능: +- **Level 1 (OPEN)**: 완전 개방, 닉네임 공유 로그인 +- **Level 2 (NAME_LIST)**: 명단 기반, allowedNames 체크 +- **Level 3 (AUTH_REQUIRED)**: 로그인 필수, 정식 계정 누구나 +- **Level 4 (EMAIL_LIST)**: 이메일 화이트리스트, allowedEmails 체크 +- **Level 5 (CLOSED)**: 닫힌 팀, 신규 가입 불가 + +### 📦 User 타입 분리 +- **FirestoreUser**: DB 저장용 (최소 데이터만) +- **User**: UI 사용용 (Firebase Auth + Firestore 결합) +- 이름, 이메일, 사진 등은 Firebase Auth가 Single Source of Truth + +### 🏷️ 닉네임 저장 위치 변경 +- ❌ 기존: `users.nicknames[teamId]` +- ✅ 신규: `team.members[uid].nickname` + +--- + +## 개요 + +- **Base URL**: `/api` (환경변수 `NEXT_PUBLIC_API_URL`로 설정 가능, 기본값 `/api`) +- **엔드포인트**: `/team`, `/user` 등 (Base URL에 `/api` 포함됨) +- **실제 호출 URL**: `{BASE_URL}{endpoint}` → `/api/team`, `/api/user` +- **인증**: Firebase ID Token을 `Authorization: Bearer {token}` 헤더로 전달 +- **응답 형식**: 모든 API는 `ApiResponse` 형식 반환 + +```typescript +interface ApiResponse { + success: boolean; + data?: T; + error?: ApiError; +} + +interface ApiError { + code: string; + message: string; + details?: any; +} +``` + +--- + +## Team API + +### 1. POST `/team` - 팀 생성 +실제 URL: `POST /api/team` + +**인증**: 필수 (정식 계정) + +**Request**: +```typescript +{ + name: string; // 팀 이름 + code: string; // 팀 코드 (고유) + securityLevel: 1 | 2 | 3 | 4 | 5; // 🆕 5단계 보안 레벨 + allowedNames?: string[]; // 🆕 Level 2용 (명단 기반) + allowedEmails?: string[]; // 🆕 Level 4용 (이메일 화이트리스트) +} + +// 보안 레벨: +// 1: OPEN (완전 개방, 닉네임 공유 로그인) +// 2: NAME_LIST (명단 기반, 등록된 이름만) +// 3: AUTH_REQUIRED (로그인 필수, 정식 계정 누구나) +// 4: EMAIL_LIST (이메일 화이트리스트) +// 5: CLOSED (닫힌 팀, 신규 가입 불가) +``` + +**Response**: +```typescript +{ + success: true, + data: { + teamId: string; + team: Team; // 생성된 팀 객체 + } +} +``` + +**권한**: 현재 로그인한 사용자가 자동으로 팀 소유자가 됨 + +--- + +### 2. GET `/team/:id` - 팀 조회 +실제 URL: `GET /api/team/:id` + +**인증**: 선택적 (공개 팀은 인증 없이 조회 가능) + +**Response**: +```typescript +{ + success: true, + data: { + team: Team; + } +} +``` + +**캐싱**: 클라이언트에서 5분간 캐싱 + +--- + +### 3. POST `/team/search` - 팀 코드로 조회 +실제 URL: `POST /api/team/search` + +**인증**: 선택적 + +**Request**: +```typescript +{ + code: string; // 팀 코드 (정규화됨) +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + team: Team | null; // 팀이 없으면 null + } +} +``` + +**캐싱**: 클라이언트에서 1분간 캐싱 + +--- + +### 4. GET `/team/list` - 내 팀 목록 +실제 URL: `GET /api/team/list` + +**인증**: 필수 (정식 계정) + +**Response**: +```typescript +{ + success: true, + data: { + teams: Team[]; + } +} +``` + +**권한**: 로그인한 사용자가 소유한 팀 + 참여한 팀 모두 조회 (중복 제거) + +**백엔드 로직**: +1. `getTeamsByOwner(uid)` - 소유한 팀 조회 +2. `getTeamsByMember(uid)` - 참여한 팀 조회 (`memberUids` array-contains) +3. 두 결과를 병합하고 중복 제거하여 반환 + +**캐싱**: 클라이언트에서 1분간 캐싱 + +--- + +### 5. PUT `/team/:id` - 팀 정보 수정 +실제 URL: `PUT /api/team/:id` + +**인증**: 필수 + +**Request**: +```typescript +{ + teamId: string; + data: { + name?: string; + securityMode?: "simple" | "normal" | "open"; + requirePin?: boolean; + allowAnonymousJoin?: boolean; + isActive?: boolean; + } +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + team: Team; + } +} +``` + +**권한**: 팀 소유자만 수정 가능 + +**캐시 무효화**: 해당 팀, 팀 목록 + +--- + +### 6. DELETE `/team/:id` - 팀 삭제 (Soft Delete) +실제 URL: `DELETE /api/team/:id` + +**인증**: 필수 + +**Request**: +```typescript +{ + teamId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**권한**: 팀 소유자만 삭제 가능 + +**캐시 무효화**: 해당 팀, 팀 목록 + +--- + +### 7. POST `/team/add-student` - 팀에 학생 추가 +실제 URL: `POST /api/team/add-student` + +**인증**: 필수 (내부 사용 - StudentManager에서 호출) + +**Request**: +```typescript +{ + teamId: string; + studentId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**캐시 무효화**: 해당 팀 + +--- + +### 8. POST `/team/remove-student` - 팀에서 학생 제거 +실제 URL: `POST /api/team/remove-student` + +**인증**: 필수 + +**Request**: +```typescript +{ + teamId: string; + studentId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**권한**: 팀 소유자만 제거 가능 (서버에서 검증) + +**캐시 무효화**: 해당 팀 + +--- + +### 9. POST `/team/generate-code` - 고유 팀 코드 생성 +실제 URL: `POST /api/team/generate-code` + +**인증**: 선택적 + +**Request**: +```typescript +{ + maxAttempts?: number; // 기본값: 10 +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + code: string; // 생성된 고유 팀 코드 + } +} +``` + +--- + +### 10. POST `/team/check-code` - 팀 코드 존재 여부 +실제 URL: `POST /api/team/check-code` + +**인증**: 선택적 + +**Request**: +```typescript +{ + code: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + exists: boolean; + } +} +``` + +--- + +## User API + +**중요**: User vs FirestoreUser 구분 +- **FirestoreUser**: DB 저장용 (uid, createdAt, lastLoginAt, settings만) +- **User**: API 응답/UI용 (Firebase Auth + FirestoreUser 결합) + +### 1. POST `/user` - 사용자 생성 +실제 URL: `POST /api/user` + +**인증**: 필수 + +**Request**: +```typescript +{ + uid: string; // Firebase Auth UID + displayName: string; // Firebase Auth displayName 설정용 + teamId: string; // 최초 가입 팀 + isAnonymous: boolean; // 익명 여부 +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + user: User; // Firebase Auth + Firestore 결합된 완전한 User + } +} +``` + +**부수 효과**: +- Firestore에 FirestoreUser 생성 (최소 데이터) +- 팀에 멤버 추가 (`team.members[uid]`) +- Firebase Auth displayName 설정 + +**캐시 무효화**: 팀별 사용자 목록 + +--- + +### 2. GET `/student/:id` - 학생 조회 +실제 URL: `GET /api/student/:id` + +**인증**: 선택적 + +**Response**: +```typescript +{ + success: true, + data: { + student: Student; + } +} +``` + +**캐싱**: 클라이언트에서 5분간 캐싱 + +--- + +### 3. POST `/student/by-uid` - Firebase UID로 학생 조회 +실제 URL: `POST /api/student/by-uid` + +**인증**: 필수 (Anonymous Auth) + +**Request**: +```typescript +{ + firebaseUid: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + student: Student | null; + } +} +``` + +**용도**: 로그인 시 기존 학생 확인 + +**캐싱**: 클라이언트에서 5분간 캐싱 + +--- + +### 4. POST `/student/by-team` - 팀별 학생 목록 +실제 URL: `POST /api/student/by-team` + +**인증**: 선택적 + +**Request**: +```typescript +{ + teamId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + students: Student[]; + } +} +``` + +**캐싱**: 클라이언트에서 30초간 캐싱 (자주 변경) + +--- + +### 5. POST `/student/find-by-name` - 이름으로 학생 찾기 +실제 URL: `POST /api/student/find-by-name` + +**인증**: 선택적 + +**Request**: +```typescript +{ + teamId: string; + name: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + student: Student | null; + } +} +``` + +**용도**: 학생 로그인 시 기존 학생 확인 + +--- + +### 6. PUT `/student/:id` - 학생 정보 수정 +실제 URL: `PUT /api/student/:id` + +**인증**: 필수 + +**Request**: +```typescript +{ + studentId: string; + data: { + name?: string; + pinHash?: string; + linkedUserId?: string; + } +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + student: Student; + } +} +``` + +**캐시 무효화**: 해당 학생, 팀별 학생 목록 + +--- + +### 7. POST `/student/kick` - 팀에서 학생 강퇴 +실제 URL: `POST /api/student/kick` + +**인증**: 필수 + +**Request**: +```typescript +{ + studentId: string; + teamId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**권한**: 팀 소유자만 강퇴 가능 + +**부수 효과**: +- `student.teamIds`에서 팀 제거 +- `team.studentIds`에서 학생 제거 + +**캐시 무효화**: 해당 학생, 팀, 팀별 학생 목록 + +--- + +### 8. POST `/student/update-pin` - PIN 변경 +실제 URL: `POST /api/student/update-pin` + +**인증**: 필수 + +**Request**: +```typescript +{ + studentId: string; + newPin: string; // 평문 (4자리 숫자) +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**검증**: 서버에서 PIN 형식 검증 (4자리 숫자) + +**보안**: PIN은 SHA-256 해시로 저장 + +**캐시 무효화**: 해당 학생 + +--- + +### 9. POST `/student/validate-pin` - PIN 검증 +실제 URL: `POST /api/student/validate-pin` + +**인증**: 선택적 + +**Request**: +```typescript +{ + studentId: string; + pin: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + valid: boolean; + } +} +``` + +**용도**: 학생 로그인 시 PIN 확인 + +--- + +### 10. POST `/student/link` - 정식 계정 연결 +실제 URL: `POST /api/student/link` + +**인증**: 필수 (정식 계정) + +**Request**: +```typescript +{ + studentId: string; + // userId는 Authorization 헤더에서 추출 +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**권한**: 현재 로그인한 사용자와 연결 + +**검증**: +- 학생이 이미 다른 계정과 연결되어 있으면 에러 + +**캐시 무효화**: 해당 학생, 내 학생 목록 + +--- + +### 11. POST `/student/unlink` - 정식 계정 연결 해제 +실제 URL: `POST /api/student/unlink` + +**인증**: 필수 + +**Request**: +```typescript +{ + studentId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**캐시 무효화**: 해당 학생, 내 학생 목록 + +--- + +### 12. GET `/student/my-students` - 내 학생 목록 +실제 URL: `GET /api/student/my-students` + +**인증**: 필수 (정식 계정) + +**Response**: +```typescript +{ + success: true, + data: { + students: Student[]; + } +} +``` + +**권한**: 로그인한 사용자가 소유한 학생만 조회 (`linkedUserId` 기준) + +**캐싱**: 클라이언트에서 1분간 캐싱 + +--- + +### 13. POST `/student/update-last-login` - 마지막 로그인 시간 업데이트 +실제 URL: `POST /api/student/update-last-login` + +**인증**: 선택적 + +**Request**: +```typescript +{ + studentId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**참고**: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음) + +--- + +## Writing API + +### 1. POST `/writing` - 글 생성 +실제 URL: `POST /api/writing` + +**인증**: 필수 + +**Request**: +```typescript +{ + title: string; + content: string; + status?: "draft" | "published"; + topicId?: string | null; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + writingId: string; + writing: Writing; + } +} +``` + +**부수 효과**: 서버에서 wordCount, charCount 자동 계산 + +**캐시 무효화**: 사용자 글 목록, 최근 글 + +--- + +### 2. GET `/writing/:id` - 글 조회 +실제 URL: `GET /api/writing/:id` + +**인증**: 선택적 + +**Response**: +```typescript +{ + success: true, + data: { + writing: Writing | null; + } +} +``` + +**캐싱**: 클라이언트에서 5분간 캐싱 + +--- + +### 3. POST `/writing/user` - 사용자의 글 목록 +실제 URL: `POST /api/writing/user` + +**인증**: 필수 + +**Request**: +```typescript +{ + userId?: string; // 없으면 현재 사용자 +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + writings: Writing[]; + } +} +``` + +**캐싱**: 클라이언트에서 1분간 캐싱 + +--- + +### 4. POST `/writing/recent` - 최근 글 +실제 URL: `POST /api/writing/recent` + +**인증**: 필수 + +**Request**: +```typescript +{ + limit?: number; // 기본값: 5 +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + writings: Writing[]; + } +} +``` + +**캐싱**: 클라이언트에서 30초간 캐싱 + +--- + +### 5. PUT `/writing/:id` - 글 수정 +실제 URL: `PUT /api/writing/:id` + +**인증**: 필수 + +**Request**: +```typescript +{ + writingId: string; + data: { + title?: string; + content?: string; + status?: "draft" | "published"; + topicId?: string | null; + wordCount?: number; + charCount?: number; + } +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + writing: Writing; + } +} +``` + +**권한**: 작성자만 수정 가능 + +**캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글 + +--- + +### 6. DELETE `/writing/:id` - 글 삭제 +실제 URL: `DELETE /api/writing/:id` + +**인증**: 필수 + +**Request**: +```typescript +{ + writingId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**권한**: 작성자만 삭제 가능 + +**캐시 무효화**: 해당 글, 사용자 글 목록, 최근 글 + +--- + +## Topic API + +### 1. POST `/topic/available` - 사용 가능한 주제 목록 +실제 URL: `POST /api/topic/available` + +**인증**: 필수 + +**Request**: +```typescript +{ + teamIds?: string[]; // 팀 주제를 가져올 팀 ID 목록 +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + topics: Topic[]; + } +} +``` + +**백엔드 로직**: +1. 개인 주제: `ownerType === PERSONAL && ownerId === currentUserId` +2. 팀 주제: `ownerType === TEAM && ownerId in [teamId1, teamId2, ...]` + - 클라이언트에서 `teamIds: ["team1", "team2"]` 전달 + - 서버에서 필터링 +3. 모든 주제를 병합하여 반환 + +**캐싱**: 클라이언트에서 5분간 캐싱 + +**참고**: 그룹(Group) 기능은 제거되었습니다. 팀(Team) 기능만 사용합니다. + +--- + +### 2. GET `/topic/:id` - 주제 조회 +실제 URL: `GET /api/topic/:id` + +**인증**: 선택적 + +**Response**: +```typescript +{ + success: true, + data: { + topic: Topic | null; + } +} +``` + +**캐싱**: 클라이언트에서 5분간 캐싱 + +--- + +### 3. POST `/topic` - 개인 주제 생성 +실제 URL: `POST /api/topic` + +**인증**: 필수 + +**Request**: +```typescript +{ + title: string; + description: string; + category: "daily" | "imagination" | "emotion" | "experience"; + difficulty: "easy" | "medium" | "hard"; + keywords?: string[]; + examplePrompts?: string[]; + titleTemplate?: string; + contentTemplate?: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + topicId: string; + topic: Topic; + } +} +``` + +**권한**: 로그인한 사용자가 자동으로 소유자가 됨 + +**캐시 무효화**: 주제 목록 + +--- + +### 4. PUT `/topic/:id` - 개인 주제 수정 +실제 URL: `PUT /api/topic/:id` + +**인증**: 필수 + +**Request**: +```typescript +{ + topicId: string; + data: { + title?: string; + description?: string; + category?: "daily" | "imagination" | "emotion" | "experience"; + difficulty?: "easy" | "medium" | "hard"; + keywords?: string[]; + examplePrompts?: string[]; + titleTemplate?: string; + contentTemplate?: string; + isActive?: boolean; + } +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + topic: Topic; + } +} +``` + +**권한**: 주제 소유자만 수정 가능 + +**캐시 무효화**: 해당 주제, 주제 목록 + +--- + +### 5. DELETE `/topic/:id` - 개인 주제 삭제 +실제 URL: `DELETE /api/topic/:id` + +**인증**: 필수 + +**Request**: +```typescript +{ + topicId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**권한**: 주제 소유자만 삭제 가능 + +**캐시 무효화**: 해당 주제, 주제 목록 + +--- + +### 6. POST `/topic/increment-usage` - 주제 사용 횟수 증가 +실제 URL: `POST /api/topic/increment-usage` + +**인증**: 선택적 + +**Request**: +```typescript +{ + topicId: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**참고**: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음) + +--- + +### 7. POST `/topic/team` - 팀 주제 생성 +실제 URL: `POST /api/topic/team` + +**인증**: 필수 (팀 소유자만) + +**Request**: +```typescript +{ + teamId: string; // 팀 ID (ownerId = teamId) + title: string; + description: string; + category: "daily" | "imagination" | "emotion" | "experience"; + difficulty: "easy" | "medium" | "hard"; + keywords?: string[]; + examplePrompts?: string[]; + titleTemplate?: string; + contentTemplate?: string; +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + topicId: string; + topic: Topic; // ownerType: TEAM, ownerId: teamId + } +} +``` + +**권한**: +- 로그인한 사용자가 팀 소유자인지 확인 (`team.ownerId === currentUserId`) +- 팀이 활성화 상태인지 확인 (`team.isActive === true`) + +**백엔드 처리**: +```typescript + +const topic = { + ...requestData, + ownerType: TopicOwnerType.TEAM, + ownerId: teamId, + createdBy: currentUserId, + usageCount: 0, + isActive: true, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), +}; +``` + +**캐시 무효화**: 주제 목록, 팀 주제 목록 + +--- + +### 8. GET `/topic/team/:teamId` - 팀 주제 목록 조회 +실제 URL: `GET /api/topic/team/:teamId` + +**인증**: 선택적 (공개 조회 가능) + +**Response**: +```typescript +{ + success: true, + data: { + topics: Topic[]; // ownerType === TEAM && ownerId === teamId + } +} +``` + +**백엔드 로직**: +```typescript + +const topics = await db.collection('topics') + .where('ownerType', '==', TopicOwnerType.TEAM) + .where('ownerId', '==', teamId) + .where('isActive', '==', true) + .orderBy('createdAt', 'desc') + .get(); +``` + +**캐싱**: 클라이언트에서 5분간 캐싱 + +--- + +### 9. PUT `/topic/team/:id` - 팀 주제 수정 +실제 URL: `PUT /api/topic/team/:id` + +**인증**: 필수 (팀 소유자만) + +**Request**: +```typescript +{ + topicId: string; + teamId: string; // 소유권 검증용 + data: { + title?: string; + description?: string; + category?: "daily" | "imagination" | "emotion" | "experience"; + difficulty?: "easy" | "medium" | "hard"; + keywords?: string[]; + examplePrompts?: string[]; + titleTemplate?: string; + contentTemplate?: string; + isActive?: boolean; + } +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + topic: Topic; + } +} +``` + +**권한 검증**: +1. 주제가 팀 주제인지 확인: `topic.ownerType === TEAM && isTeamOwnerId(topic.ownerId)` +2. 요청한 teamId와 주제의 teamId 일치 확인: `extractTeamId(topic.ownerId) === teamId` +3. 현재 사용자가 팀 소유자인지 확인: `team.ownerId === currentUserId` + +**캐시 무효화**: 해당 주제, 주제 목록, 팀 주제 목록 + +--- + +### 10. DELETE `/topic/team/:id` - 팀 주제 삭제 +실제 URL: `DELETE /api/topic/team/:id` + +**인증**: 필수 (팀 소유자만) + +**Request**: +```typescript +{ + topicId: string; + teamId: string; // 소유권 검증용 +} +``` + +**Response**: +```typescript +{ + success: true, + data: { + success: true; + } +} +``` + +**권한 검증**: PUT과 동일 + +**캐시 무효화**: 해당 주제, 주제 목록, 팀 주제 목록 + +**참고**: Soft delete 방식 (`isActive: false`로 설정) + +--- + +## 에러 코드 + +| 코드 | 설명 | +|------|------| +| `UNAUTHORIZED` | 인증 필요 | +| `FORBIDDEN` | 권한 없음 | +| `NOT_FOUND` | 리소스 없음 | +| `VALIDATION_ERROR` | 유효성 검사 실패 | +| `TEAM_CODE_INVALID` | 팀 코드 형식 오류 | +| `TEAM_INACTIVE` | 비활성화된 팀 | +| `PIN_REQUIRED` | PIN 입력 필요 | +| `PIN_INVALID` | PIN 불일치 | +| `STUDENT_NAME_DUPLICATE` | 팀 내 이름 중복 | +| `ALREADY_EXISTS` | 리소스 중복 | +| `INTERNAL_ERROR` | 서버 오류 | + +--- + +## 구현 노트 + +### Next.js API Routes 구현 예시 + +```typescript +// app/api/team/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { verifyIdToken } from '@/lib/auth'; +import { createTeam } from '@/services/teamService'; + +export async function POST(request: NextRequest) { + try { + // 1. 인증 확인 + const token = request.headers.get('Authorization')?.replace('Bearer ', ''); + const decoded = await verifyIdToken(token); + + if (!decoded) { + return NextResponse.json({ + success: false, + error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } + }, { status: 401 }); + } + + // 2. Request 파싱 + const body = await request.json(); + + // 3. 유효성 검사 + if (!body.name || !body.code) { + return NextResponse.json({ + success: false, + error: { code: 'VALIDATION_ERROR', message: '필수 필드가 누락되었습니다.' } + }, { status: 400 }); + } + + // 4. 비즈니스 로직 실행 + const teamId = await createTeam({ + ...body, + ownerId: decoded.uid + }); + + // 5. 응답 + return NextResponse.json({ + success: true, + data: { teamId, team: {...} } + }); + + } catch (error: any) { + return NextResponse.json({ + success: false, + error: { code: 'INTERNAL_ERROR', message: error.message } + }, { status: 500 }); + } +} +``` + +### Server Actions 구현 예시 + +```typescript +// app/actions/team.ts +'use server' + +import { auth } from '@/lib/auth'; +import { createTeam as createTeamFirestore } from '@/services/teamService'; +import type { ApiResponse } from '@/types/api'; +import type { CreateTeamRequest, CreateTeamResponse } from '@/types/api/team'; + +export async function createTeam(data: CreateTeamRequest): Promise> { + try { + const session = await auth(); + + if (!session) { + return { + success: false, + error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } + }; + } + + const teamId = await createTeamFirestore({ + ...data, + ownerId: session.uid + }); + + return { + success: true, + data: { teamId, team: {...} } + }; + } catch (error: any) { + return { + success: false, + error: { code: 'INTERNAL_ERROR', message: error.message } + }; + } +} +``` + +--- + +## 보안 고려사항 + +1. **인증 토큰 검증**: 모든 쓰기 작업은 Firebase ID Token 검증 필수 +2. **권한 체크**: 팀 소유자 확인 (ownerId === decoded.uid) +3. **입력 검증**: 모든 입력값 sanitization 및 validation +4. **Rate Limiting**: Redis로 API 호출 횟수 제한 (선택적) +5. **PIN 보안**: PIN은 평문으로 받아 서버에서 SHA-256 해시로 저장 + +--- + +## Redis 캐싱 전략 (서버 사이드) + +### 캐싱 대상 +- 팀 정보: `redis:team:{teamId}` - TTL 5분 +- 팀 코드 조회: `redis:team:code:{code}` - TTL 1분 +- 학생 정보: `redis:student:{studentId}` - TTL 5분 +- 팀별 학생 목록: `redis:students:team:{teamId}` - TTL 30초 + +### 캐시 무효화 +- 팀 생성/수정/삭제 시: 해당 팀 + 팀 목록 +- 학생 생성/수정 시: 해당 학생 + 팀별 학생 목록 +- 강퇴 시: 학생 + 팀 + 팀별 학생 목록 + +--- + +## 클라이언트 캐싱 전략 + +매니저 레벨에서 in-memory 캐싱 (SingletonManager): +- **조회 작업**: 캐싱 활성화 (GET 요청) +- **변경 작업**: 캐싱 안 함 (POST/PUT/DELETE) +- **캐시 무효화**: 변경 작업 시 관련 캐시 자동 무효화 + +--- + +## 개발 순서 + +1. ✅ API 타입 정의 (`src/types/api.ts`, `src/types/api/team.ts`, `src/types/api/student.ts`) +2. ✅ BaseManager에 `authenticatedFetch`, `callApi` 구현 +3. ✅ BaseManager에 클라이언트 캐싱 메서드 구현 +4. ✅ TeamManager, StudentManager를 API 호출 방식으로 전환 +5. ⏳ Next.js API Routes 또는 Server Actions 구현 +6. ⏳ Redis 캐싱 구현 (선택적) +7. ⏳ Rate Limiting 구현 (선택적) diff --git a/DATA_MODELS.md b/DATA_MODELS.md new file mode 100644 index 0000000..f8288c0 --- /dev/null +++ b/DATA_MODELS.md @@ -0,0 +1,772 @@ +# 라온누리 - 데이터 모델 및 스키마 + +> 최종 업데이트: 2025-11-07 + +이 문서는 Firestore 데이터베이스 구조와 TypeScript 타입 정의를 설명합니다. + +**참고**: API 타입 정의는 [API_SPEC.md](./API_SPEC.md)를 참조하세요. + +--- + +## Firestore 컬렉션 구조 + +### 컬렉션 개요 + +``` +firestore +├── teams/ # ✅ 팀 (팀 코드 시스템) +├── students/ # ✅ 학생 계정 (Anonymous Auth) +├── users/ # 🔜 사용자 프로필 및 진행 상황 +├── writings/ # ✅ 작성한 글 +├── topics/ # ✅ 글쓰기 주제 +├── lessons/ # 🔜 학습 레슨 +├── stickers/ # 🔜 스티커 마스터 데이터 +└── userStickers/ # 🔜 사용자별 스티커 획득 기록 +``` + +**범례**: +- ✅ 구현 완료 +- 🔜 구현 예정 + +--- + +## 1. Team (팀) ✅ + +**컬렉션**: `teams/{teamId}` + +### 스키마 + +```typescript +interface Team { + id: string; // 문서 ID + code: string; // 팀 코드 (예: "춤추는파란사자") + name: string; // 팀 이름 (예: "2학년 1반") + ownerId: string; // 팀 소유자 UID (정식 계정) + + // 보안 설정 + securityMode: 'simple' | 'normal' | 'open'; + requirePin: boolean; // PIN 입력 필요 여부 + allowAnonymousJoin: boolean; // 명단 없는 학생 자동 가입 허용 + + // 멤버 + studentIds: string[]; // students 컬렉션 참조 + + // 타임스탬프 + createdAt: Timestamp; + updatedAt: Timestamp; + isActive: boolean; // 활성 상태 +} +``` + +### 보안 모드 + +| 모드 | requirePin | allowAnonymousJoin | 설명 | +|------|-----------|-------------------|------| +| `simple` | false | true | 팀 코드 + 이름 (초등 저학년) | +| `normal` | true | false | 팀 코드 + 이름 + PIN (보안 강화) | +| `open` | false | true | 누구나 자유롭게 참여 | + +### 예시 데이터 + +```json +{ + "id": "team_abc123", + "code": "춤추는파란사자", + "name": "2학년 1반", + "ownerId": "teacher_xyz", + + "securityMode": "simple", + "requirePin": false, + "allowAnonymousJoin": true, + + "studentIds": ["student_001", "student_002", "student_003"], + + "createdAt": "2024-11-06T00:00:00Z", + "updatedAt": "2024-11-07T10:00:00Z", + "isActive": true +} +``` + +### 인덱스 + +- `code` (단일 필드, 고유) +- `ownerId` + `isActive` (복합 인덱스) + +**TypeScript**: `src/types/team.ts` + +--- + +## 2. Student (학생 계정) ✅ + +**컬렉션**: `students/{studentId}` + +### 스키마 + +```typescript +interface Student { + id: string; // 문서 ID + firebaseUid: string; // Firebase Anonymous Auth UID + linkedUserId?: string; // 연결된 정식 계정 UID (1:1, 선택적) + + // 기본 정보 + name: string; // 학생 이름 + + // 보안 + pinHash?: string; // SHA-256 해시된 PIN + + // 팀 정보 + teamIds: string[]; // 속한 팀 ID 배열 (다중 팀 지원) + + // 메타데이터 + isAnonymous: boolean; // true (Anonymous Auth) + createdAt: Timestamp; + lastLoginAt: Timestamp; +} +``` + +### 예시 데이터 + +```json +{ + "id": "student_001", + "firebaseUid": "anon_xyz123", + "linkedUserId": "user_parent_abc", + + "name": "김민지", + + "pinHash": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + + "teamIds": ["team_abc123", "team_def456"], + + "isAnonymous": true, + "createdAt": "2024-11-06T09:00:00Z", + "lastLoginAt": "2024-11-07T14:30:00Z" +} +``` + +### 인덱스 + +- `firebaseUid` (단일 필드, 고유) +- `linkedUserId` (단일 필드) +- `teamIds` (array-contains) + +**TypeScript**: `src/types/student.ts` + +--- + +## 3. User (사용자) + +**컬렉션**: `users/{userId}` (🔜 구현 예정) + +### 스키마 + +```typescript +interface User { + // Firebase Auth 정보 + uid: string; // Firebase UID (문서 ID와 동일) + email: string; // 이메일 + displayName: string; // 표시 이름 + photoURL?: string; // 프로필 사진 URL (선택) + createdAt: Timestamp; // 가입일 + updatedAt: Timestamp; // 최종 수정일 + + // 게임화 데이터 + level: number; // 현재 레벨 (1부터 시작) + experience: number; // 현재 경험치 (XP) + totalStickers: number; // 획득한 스티커 총 개수 + + // 학습 통계 + writingCount: number; // 작성한 글 수 + completedLessons: string[]; // 완료한 레슨 ID 배열 + currentStreak: number; // 연속 출석일 + longestStreak: number; // 최장 연속 출석일 + + // 활동 기록 + lastLoginAt: Timestamp; // 마지막 로그인 시간 + lastWritingAt?: Timestamp; // 마지막 글 작성 시간 +} +``` + +### 예시 데이터 + +```json +{ + "uid": "abc123xyz", + "email": "student@example.com", + "displayName": "김학생", + "photoURL": "https://example.com/photo.jpg", + "createdAt": "2024-10-28T00:00:00Z", + "updatedAt": "2024-10-28T10:30:00Z", + + "level": 5, + "experience": 450, + "totalStickers": 12, + + "writingCount": 8, + "completedLessons": ["lesson_001", "lesson_002", "lesson_003"], + "currentStreak": 3, + "longestStreak": 7, + + "lastLoginAt": "2024-10-28T10:00:00Z", + "lastWritingAt": "2024-10-27T15:30:00Z" +} +``` + +### 인덱스 + +- `email` (단일 필드) +- `level` (단일 필드, 내림차순) +- `writingCount` (단일 필드, 내림차순) + +--- + +## 4. Writing (작성글) ✅ + +**컬렉션**: `writings/{writingId}` + +### 스키마 + +```typescript +interface Writing { + id: string; // 문서 ID + userId: string; // 작성자 UID (TODO: studentId로 변경 예정) + topicId?: string | null; // 주제 ID (null은 자유 주제) + + // 글 내용 + title: string; // 제목 + content: string; // 본문 내용 (HTML) + wordCount: number; // 단어 수 + charCount: number; // 글자 수 + + // 상태 + status: 'draft' | 'published'; // 임시저장/발행 + + // 타임스탬프 + createdAt: Timestamp; // 최초 작성일 + updatedAt: Timestamp; // 최종 수정일 +} +``` + +**현재 구현**: +- ✅ CRUD 기능 완료 (WritingManager) +- 🔜 피드백 시스템 (향후 추가) +- 🔜 userId → studentId 마이그레이션 예정 + +### 예시 데이터 + +```json +{ + "id": "writing_001", + "userId": "abc123xyz", + "topicId": "topic_daily_001", + + "title": "내가 좋아하는 계절", + "content": "나는 가을을 제일 좋아해요. 왜냐하면...", + "wordCount": 120, + + "status": "submitted", + + "createdAt": "2024-10-27T14:00:00Z", + "updatedAt": "2024-10-27T15:00:00Z", + "submittedAt": "2024-10-27T15:00:00Z", + + "feedback": { + "authorId": "parent_abc", + "authorName": "김엄마", + "content": "계절의 특징을 잘 표현했어요!", + "rating": 5, + "createdAt": "2024-10-27T20:00:00Z" + } +} +``` + +### 인덱스 + +- `userId` + `createdAt` (복합 인덱스, 내림차순) +- `topicId` + `createdAt` (복합 인덱스, 내림차순) +- `status` + `createdAt` (복합 인덱스) + +--- + +## 5. Topic (글쓰기 주제) ✅ + +**컬렉션**: `topics/{topicId}` + +### 스키마 + +```typescript +interface Topic { + id: string; // 문서 ID + title: string; // 주제 제목 + description: string; // 주제 설명 + + // 분류 + category: 'daily' | 'imagination' | 'emotion' | 'experience'; + difficulty: 'easy' | 'medium' | 'hard'; + + // 소유자 정보 + ownerType: 'system' | 'team' | 'personal'; + ownerId: string; // 팀 주제: teamId, 개인 주제: userId + + // 메타데이터 + keywords: string[]; // 관련 키워드 + examplePrompts: string[]; // 예시 질문/프롬프트 + + // 템플릿 (선택적) + titleTemplate?: string; // 제목 템플릿 (예: "{date} 일기") + contentTemplate?: string; // 내용 템플릿 + + // 통계 + usageCount: number; // 사용된 횟수 + + // 타임스탬프 + createdAt: Timestamp; // 생성일 + updatedAt: Timestamp; // 수정일 + + // 관리자 정보 + createdBy: string; // 작성자 UID + isActive: boolean; // 활성화 여부 +} +``` + +**주제 소유 유형**: +- `system`: 시스템 기본 주제 (모든 사용자) - 미래 확장용 +- `team`: 팀 주제 (팀 멤버만, `ownerId = teamId`) +- `personal`: 개인 주제 (작성자만) + +**현재 구현**: +- ✅ CRUD 기능 완료 (TopicManager) +- ✅ 개인 주제 생성/수정/삭제 +- ✅ 팀 주제 생성/수정/삭제 +- ✅ 템플릿 처리 (클라이언트) +- ✅ TopicSelector에 팀/개인 배지 표시 + +### 카테고리 설명 + +| 카테고리 | 설명 | 예시 | +|---------|------|------| +| `daily` | 일상 경험 | "오늘 있었던 일", "가족과 함께한 시간" | +| `imagination` | 상상과 창작 | "만약 내가 슈퍼히어로라면", "꿈속에서의 모험" | +| `emotion` | 감정 표현 | "가장 행복했던 순간", "속상했던 경험" | +| `experience` | 특별한 경험 | "처음 해본 것", "여행에서의 추억" | + +### 예시 데이터 + +```json +{ + "id": "topic_daily_001", + "title": "내가 좋아하는 계절", + "description": "사계절 중 가장 좋아하는 계절과 그 이유를 써보아요.", + + "category": "daily", + "difficulty": "easy", + + "keywords": ["계절", "봄", "여름", "가을", "겨울", "날씨"], + "examplePrompts": [ + "어떤 계절을 가장 좋아하나요?", + "그 계절에는 무엇을 하나요?", + "왜 그 계절이 좋은가요?" + ], + + "usageCount": 145, + + "createdAt": "2024-10-01T00:00:00Z", + "updatedAt": "2024-10-01T00:00:00Z", + + "createdBy": "admin_001", + "isActive": true +} +``` + +### 인덱스 + +- `category` + `difficulty` (복합 인덱스) +- `isActive` + `usageCount` (복합 인덱스, 내림차순) + +--- + +## 6. Lesson (학습 레슨) 🔜 + +**컬렉션**: `lessons/{lessonId}` (구현 예정) + +### 스키마 + +```typescript +interface Lesson { + id: string; // 문서 ID + title: string; // 레슨 제목 + description: string; // 레슨 설명 + + // 분류 + level: number; // 추천 레벨 (1-100) + category: string; // 카테고리 (예: "문단 쓰기", "비유 표현") + order: number; // 순서 (같은 카테고리 내) + + // 콘텐츠 + content: { + theory: string; // 이론 설명 (마크다운) + examples: string[]; // 예시 문장들 + exercises: Exercise[]; // 연습 문제 + }; + + // 선행 조건 + requiredLessons?: string[]; // 선행 레슨 ID 배열 + + // 보상 + reward: { + experience: number; // 획득 경험치 + stickers?: string[]; // 획득 가능한 스티커 ID + }; + + // 통계 + completionCount: number; // 완료한 학생 수 + averageScore?: number; // 평균 점수 (0-100) + + // 타임스탬프 + createdAt: Timestamp; + updatedAt: Timestamp; + + // 관리자 + createdBy: string; + isActive: boolean; +} + +interface Exercise { + id: string; + type: 'multiple_choice' | 'short_answer' | 'writing'; + question: string; + options?: string[]; // 객관식인 경우 + correctAnswer?: string | number; + explanation?: string; // 해설 +} +``` + +### 예시 데이터 + +```json +{ + "id": "lesson_001", + "title": "문장 부호 사용하기", + "description": "마침표, 물음표, 느낌표를 올바르게 사용하는 방법을 배워요.", + + "level": 1, + "category": "기초 문법", + "order": 1, + + "content": { + "theory": "# 문장 부호란?\n문장의 끝에는 마침표(.), 물음표(?), 느낌표(!)를 사용해요.", + "examples": [ + "오늘은 날씨가 좋아요.", + "너는 어디에 가니?", + "와, 정말 멋지다!" + ], + "exercises": [ + { + "id": "ex_001", + "type": "multiple_choice", + "question": "다음 중 올바른 문장은?", + "options": [ + "오늘은 날씨가 좋아요", + "오늘은 날씨가 좋아요.", + "오늘은 날씨가 좋아요?" + ], + "correctAnswer": 1, + "explanation": "평서문은 마침표(.)로 끝나요." + } + ] + }, + + "reward": { + "experience": 50, + "stickers": ["sticker_beginner_001"] + }, + + "completionCount": 234, + "averageScore": 87.5, + + "createdAt": "2024-10-01T00:00:00Z", + "updatedAt": "2024-10-01T00:00:00Z", + + "createdBy": "admin_001", + "isActive": true +} +``` + +### 인덱스 + +- `category` + `order` (복합 인덱스, 오름차순) +- `level` + `order` (복합 인덱스, 오름차순) + +--- + +## 7. Sticker (스티커) 🔜 + +**컬렉션**: `stickers/{stickerId}` (구현 예정) + +### 스키마 + +```typescript +interface Sticker { + id: string; // 문서 ID + name: string; // 스티커 이름 + imageUrl: string; // 이미지 URL + description: string; // 설명 + + // 분류 + category: 'learning' | 'challenge' | 'special'; // 카테고리 + rarity: 'common' | 'rare' | 'epic' | 'legendary'; // 희귀도 + + // 획득 조건 + unlockCondition: { + type: 'writing_count' | 'lesson_completion' | 'level' | 'streak' | 'manual'; + value?: number; // 조건 값 (type에 따라 다름) + lessonId?: string; // lesson_completion인 경우 + }; + + // 통계 + unlockCount: number; // 획득한 사용자 수 + + // 타임스탬프 + createdAt: Timestamp; + createdBy: string; + isActive: boolean; +} +``` + +### 희귀도 및 카테고리 + +| 희귀도 | 설명 | 예시 | +|-------|------|------| +| `common` | 일반 | 기본 달성 스티커 | +| `rare` | 레어 | 10개 글 작성 | +| `epic` | 에픽 | 50개 글 작성, 레벨 20 달성 | +| `legendary` | 전설 | 100개 글 작성, 모든 레슨 완료 | + +| 카테고리 | 설명 | +|---------|------| +| `learning` | 학습 관련 (레슨 완료) | +| `challenge` | 도전 과제 (글 수, 연속 출석) | +| `special` | 특별 이벤트 | + +### 예시 데이터 + +```json +{ + "id": "sticker_beginner_001", + "name": "첫 글쓰기", + "imageUrl": "https://example.com/stickers/first_writing.png", + "description": "첫 번째 글을 완성했어요!", + + "category": "challenge", + "rarity": "common", + + "unlockCondition": { + "type": "writing_count", + "value": 1 + }, + + "unlockCount": 856, + + "createdAt": "2024-10-01T00:00:00Z", + "createdBy": "admin_001", + "isActive": true +} +``` + +### 인덱스 + +- `category` + `rarity` (복합 인덱스) +- `unlockCount` (단일 필드, 내림차순) + +--- + +## 8. UserSticker (사용자 스티커) 🔜 + +**컬렉션**: `userStickers/{userStickerId}` (구현 예정) + +### 스키마 + +```typescript +interface UserSticker { + id: string; // 문서 ID + userId: string; // 사용자 UID + stickerId: string; // 스티커 ID + unlockedAt: Timestamp; // 획득 시간 +} +``` + +### 예시 데이터 + +```json +{ + "id": "us_abc123_sticker001", + "userId": "abc123xyz", + "stickerId": "sticker_beginner_001", + "unlockedAt": "2024-10-27T15:30:00Z" +} +``` + +### 인덱스 + +- `userId` + `unlockedAt` (복합 인덱스, 내림차순) +- `userId` + `stickerId` (복합 인덱스, 중복 방지용) + +--- + +## TypeScript 타입 정의 파일 + +### 데이터 모델 타입 + +모든 데이터 모델 타입은 `src/types/` 디렉토리에 정의됩니다. + +``` +src/types/ +├── team.ts # ✅ Team 데이터 모델 +├── student.ts # ✅ Student 데이터 모델 +├── writing.ts # ✅ Writing 데이터 모델 +├── topic.ts # ✅ Topic 데이터 모델 +├── user.ts # 🔜 User 관련 타입 (예정) +├── lesson.ts # 🔜 Lesson 관련 타입 (예정) +├── sticker.ts # 🔜 Sticker 관련 타입 (예정) +└── api/ # ✅ API Request/Response 타입 + ├── team.ts # Team API 타입 (10개 엔드포인트) + ├── student.ts # Student API 타입 (13개 엔드포인트) + ├── writing.ts # Writing API 타입 (6개 엔드포인트) + └── topic.ts # Topic API 타입 (6개 엔드포인트) +``` + +### 사용 예시 + +```typescript +// 데이터 모델 import +import type { Team } from "@/types/team"; +import type { Student } from "@/types/student"; +import type { Writing } from "@/types/writing"; + +// API 타입 import +import type { CreateTeamRequest, CreateTeamResponse } from "@/types/api/team"; +import type { GetStudentsByTeamResponse } from "@/types/api/student"; + +// Manager에서 사용 +import { teamManager, studentManager } from "@/managers"; +const teams = await teamManager.getMyTeams(); // Team[] 반환 +``` + +### API 공통 타입 + +`src/types/api.ts`에 정의된 공통 타입: + +```typescript +// 표준 응답 형식 +interface ApiResponse { + success: boolean; + data?: T; + error?: ApiError; +} + +// 에러 형식 +interface ApiError { + code: string; + message: string; + details?: any; +} + +// HTTP Method Enum +enum HttpMethod { + GET = "GET", + POST = "POST", + PUT = "PUT", + DELETE = "DELETE", +} +``` + +--- + +## Firestore 보안 규칙 + +### 파일 위치 + +- `firestore.rules` (프로젝트 루트) + +### 기본 규칙 예시 + +**참고**: 실제 구현 시 Next.js API Routes/Server Actions에서 권한 체크 수행 + +```javascript +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + + // 팀: 읽기는 모두 가능, 쓰기는 서버에서만 + match /teams/{teamId} { + allow read: if request.auth != null; + allow write: if false; // API에서만 쓰기 (권한 체크) + } + + // 학생: 본인 또는 연결된 계정만 읽기, 쓰기는 서버에서만 + match /students/{studentId} { + allow read: if request.auth != null && ( + request.auth.uid == resource.data.firebaseUid || + request.auth.uid == resource.data.linkedUserId + ); + allow write: if false; // API에서만 쓰기 + } + + // 사용자는 자신의 문서만 읽고 쓸 수 있음 + match /users/{userId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + // 작성글: 작성자만 읽고 쓸 수 있음 + match /writings/{writingId} { + allow read: if request.auth != null && + resource.data.userId == request.auth.uid; + allow write: if false; // API에서만 쓰기 + } + + // 주제: 모든 인증된 사용자가 읽을 수 있음 + match /topics/{topicId} { + allow read: if request.auth != null; + allow write: if false; // API에서만 쓰기 + } + + // 레슨: 모든 인증된 사용자가 읽을 수 있음 + match /lessons/{lessonId} { + allow read: if request.auth != null; + allow write: if false; + } + + // 스티커: 모든 인증된 사용자가 읽을 수 있음 + match /stickers/{stickerId} { + allow read: if request.auth != null; + allow write: if false; + } + + // 사용자 스티커: 자신의 것만 읽을 수 있음 + match /userStickers/{userStickerId} { + allow read: if request.auth != null && + resource.data.userId == request.auth.uid; + allow write: if false; // 서버에서만 부여 + } + } +} +``` + +**보안 참고사항**: +- 모든 쓰기 작업은 Next.js API Routes에서 수행 +- API에서 Firebase Admin SDK 사용하여 Firestore 접근 +- 클라이언트 매니저는 API 호출만 수행 (Firestore 직접 접근 안 함) + +--- + +## 관련 문서 + +- [API_SPEC.md](./API_SPEC.md) - API 명세서 (35개 엔드포인트) +- [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 프로젝트 구조 +- [TECH_STACK.md](./TECH_STACK.md) - 기술 스택 +- [ROADMAP.md](./ROADMAP.md) - 개발 로드맵 +- [CLAUDE.md](./CLAUDE.md) - Claude Code 개발 가이드 + +--- + +© 2024 BlueNovaLab. All rights reserved. \ No newline at end of file diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index c58d7df..3fe1cb3 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -1,9 +1,15 @@ # 라온누리 - 프로젝트 구조 -> 최종 업데이트: 2025-11-07 (아키텍처 단순화 - Users 컬렉션 전환) +> 최종 업데이트: 2025-11-10 (5단계 보안 레벨 시스템, User 타입 최소화) 초등학생을 위한 창작 글쓰기 교육 플랫폼 +**주요 업데이트** (2025-11-10): +- 🔐 5단계 보안 레벨 시스템 (팀별 보안 정책 선택) +- 📦 User 타입 분리 (FirestoreUser / User) +- 🏷️ 닉네임 저장 위치 변경 (team.members[uid].nickname) +- 🗑️ memberUids optional (Object.keys(members) 사용) + --- ## 페이지 구조 diff --git a/ROADMAP.md b/ROADMAP.md index f5cb363..5117349 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # 라온누리 - 개발 로드맵 -> 최종 업데이트: 2025-11-07 (그룹 기능 제거, 팀 시스템 개선) +> 최종 업데이트: 2025-11-10 (5단계 보안 레벨 시스템, User 타입 최소화) 초등학생을 위한 창작 글쓰기 교육 플랫폼 개발 계획 @@ -53,6 +53,10 @@ | **UserManager 개선** | **getUser() 404 시 null 반환, 팀 코드 로그인 시 신규 사용자 생성 플로우 개선** | **2025-11-07** | | **TopicSelector UI 개선** | **드롭다운 메뉴에 팀/개인 주제 배지 표시, 선택 전에도 주제 타입 확인 가능** | **2025-11-07** | | **T_ prefix 제거** | **팀 주제 ownerId에서 T_ prefix 제거, ownerId = teamId 직접 사용** | **2025-11-07** | +| **5단계 보안 레벨 시스템** | **TeamSecurityLevel enum (1-5), 팀별 보안 정책 선택, 명단 관리 API** | **2025-11-10** | +| **User 타입 최소화** | **FirestoreUser/User 분리, Firebase Auth를 Single Source of Truth로, 데이터 중복 제거** | **2025-11-10** | +| **닉네임 저장 위치 변경** | **users.nicknames → team.members[uid].nickname 이동, TeamMember 타입 확장** | **2025-11-10** | +| **authStore DB 연동** | **combineUserData로 Firebase Auth + Firestore 자동 결합, userManager.getUser() 활용** | **2025-11-10** | ### 🚧 진행 중 diff --git a/TECH_STACK.md b/TECH_STACK.md index aca247d..70e21ce 100644 --- a/TECH_STACK.md +++ b/TECH_STACK.md @@ -262,13 +262,13 @@ Manager Layer (비즈니스 로직 + 클라이언트 캐싱) │ ├─> deleteTeam() → DELETE /team/:id │ └─> generateUniqueTeamCode() → POST /team/generate-code │ - ├─> StudentManager (싱글톤) - │ ├─> createStudent() → POST /student - │ ├─> getStudent() → GET /student/:id (5분 캐싱) - │ ├─> getStudentsByTeam() → POST /student/by-team (30초 캐싱) - │ ├─> kickStudentFromTeam() → POST /student/kick - │ ├─> validateStudentPin() → POST /student/validate-pin - │ └─> linkStudentToUser() → POST /student/link + ├─> UserManager (싱글톤) + │ ├─> createUser() → POST /user + │ ├─> getUser() → GET /user/:id (Firebase Auth + Firestore 자동 결합, 5분 캐싱) + │ ├─> getUsersByTeam() → GET /user/by-team/:teamId (30초 캐싱) + │ ├─> updateLastLogin() → POST /user/:uid/update-last-login + │ ├─> findUserByNickname() → POST /user/find-by-nickname (Level 1용) + │ └─> setUserNickname() → POST /user/:uid/nickname (DEPRECATED - 팀에서 관리) │ ├─> WritingManager (싱글톤) │ ├─> createWriting() @@ -335,14 +335,19 @@ import { teamManager } from "@/managers"; // 팀 목록 조회 - 소유한 팀 + 참여한 팀 (1분간 캐싱됨) const teams = await teamManager.getMyTeams(); -// 팀 생성 (캐시 자동 무효화) +// 🆕 팀 생성 (5단계 보안 레벨) const teamId = await teamManager.createTeam({ name: "우리반", code: "춤추는파란사자", - securityMode: "simple", - requirePin: false, - allowAnonymousJoin: true + securityLevel: 2, // 1-5 (명단 기반) + allowedNames: ["홍길동", "김철수"] }); + +// 🆕 보안 레벨 변경 +await teamManager.updateSecurityLevel(teamId, 4, true); // Level 4, 자동 명단 생성 + +// 🆕 닉네임 조회 +const nickname = teamManager.getMemberNickname(team, uid, user?.name); ``` **참고 문서**: