# 라온누리 - 데이터 모델 및 스키마 이 문서는 Firestore 데이터베이스 및 Firebase Realtime Database 구조와 TypeScript 타입 정의를 설명합니다. **참고**: API 타입 정의는 [API_SPEC.md](./API_SPEC.md)를 참조하세요. --- ## 데이터베이스 구조 ### Firestore (영구 데이터) ``` firestore ├── teams/ # ✅ 팀 ├── users/ # ✅ 사용자 프로필 및 메타데이터 ├── writings/ # ✅ 작성한 글 ├── topics/ # ✅ 글쓰기 주제 ├── comments/ # 🆕 댓글 (계층 구조 지원) ├── userReactions/ # 🆕 댓글 반응 ├── patternAnalyses/ # ✅ 패턴 분석 결과 (contentHash 캐싱) ├── curriculums/ # ✅ 커리큘럼 (시스템 제공 + 팀 생성) ├── lessons/ # ✅ 학습 레슨 (theory/quiz/mission/writing_prompt) ├── stickers/ # 🔜 스티커 마스터 데이터 └── userStickers/ # 🔜 사용자별 스티커 획득 기록 ``` ### Firebase Realtime Database (실시간 데이터) ``` realtime-db ├── monitoring/ # 🆕 실시간 글쓰기 모니터링 │ └── {teamId}/ │ └── {topicId}/ │ └── {userId}/ ├── previewRequests/ # 🆕 미리보기 요청 │ └── {userId}/ ├── previewResponses/ # 🆕 미리보기 응답 │ └── {requestId}/ ``` **범례**: - ✅ 구현 완료 - 🔜 구현 예정 --- ## 1. Team (팀) ✅ **컬렉션**: `teams/{teamId}` ### 스키마 ```typescript interface Team { id: string; // 문서 ID name: string; // 팀 이름 (예: "2학년 1반") ownerId: string; // 팀 소유자 UID // AI 글쓰기 도우미 설정 aiAssistanceConfig?: AIAssistanceConfig; // 공개 설정 isPublic?: boolean; // 팀 공개 여부 (기본: false) allowPublicWritings?: boolean; // 외부 글 공개 허용 (기본: false) description?: string; // 팀 소개 (공개 팀용) coverImage?: string; // 🆕 팀 커버 이미지 URL (Firebase Storage) // 멤버 관리 (uid를 키로 사용) members: { [uid: string]: TeamMember; // 팀별 메타데이터 }; // 타임스탬프 createdAt: Timestamp; updatedAt: Timestamp; isActive: boolean; // 활성 상태 } ``` ### 예시 데이터 ```json { "id": "team_abc123", "code": "춤추는 파란 사자", "name": "2학년 1반", "ownerId": "user_xyz", "isPublic": true, "allowPublicWritings": false, "description": "창의적인 글쓰기를 배우는 우리 반입니다!", "coverImage": "https://storage.googleapis.com/raonnuri-84830.firebasestorage.app/teams/team_abc123/cover-1732688400000.png", "members": { "user_001": { "joinedAt": "2024-11-06T00:00:00Z", "role": "member", "nickname": "춤추는 작가" } }, "createdAt": "2024-11-06T00:00:00Z", "updatedAt": "2024-11-27T10:00:00Z", "isActive": true } ``` ### 인덱스 - `code` (단일 필드, 고유) - `ownerId` + `isActive` (복합 인덱스) **TypeScript**: `src/types/team.ts` --- ## 2. 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 SpellingError { original: string; // 틀린 단어 correction: string; // 올바른 단어 reason: string; // 이유 } // AI 분석 결과 (개별 글) interface WritingAnalysis { score: number; // AI 평가 점수 (0~100) breakdown: { sensory: number; // 오감 표현 점수 (4점 만점) emotion: number; // 감정 표현 점수 (2점 만점) dialogue: number; // 대화 표현 점수 (2점 만점) onomatopoeia: number; // 의성어 점수 (2점 만점) }; foundWords: { sensory: string[]; // 발견된 오감 단어 emotion: string[]; // 발견된 감정 단어 onomatopoeia: string[]; // 발견된 의성어 }; suggestions?: string[]; // AI 제안 사항 spellingErrors?: SpellingError[]; // 맞춤법 오류 목록 analyzedAt: Date; // 분석 시간 contentHash: string; // 분석 시점의 content 해시 (SHA-256) } // AI 생성 이미지 정보 interface GeneratedImage { url: string; // Firebase Storage에 저장된 이미지 URL prompt: string; // 생성에 사용된 프롬프트 generatedAt: Timestamp; // 생성 시각 modelName: string; // "imagen-4.0-generate-001" bytesBase64?: string; // (선택) base64 인코딩 원본 } // 이미지 왜곡 영역 정보 (responsive-image-canvas) interface DistortionAreaData { id: string; basePoints: Array<{x: number; y: number}>; movement: { preset?: 'none' | 'horizontal' | 'vertical' | 'rotate-cw' | 'rotate-ccw' | 'pulse' | 'diagonal-1' | 'diagonal-2'; vectorA: {x: number; y: number}; vectorB: {x: number; y: number}; duration: number; easing: string; strength?: number; }; distortionStrength: number; physics?: { stiffness: number; damping: number; mass: number; influenceRadius: number; maxStrength: number; }; } interface Writing { id: string; // 문서 ID userId: string; // 작성자 UID topicId?: string | null; // 주제 ID (null은 자유 주제) // 글 내용 title: string; // 제목 content: string; // 본문 내용 (HTML) wordCount: number; // 단어 수 charCount: number; // 글자 수 // 상태 status: 'draft' | 'published'; // 임시저장/발행 commentCount: number; // 🆕 댓글 총 개수 (기본값: 0) // 공개 설정 teamId?: string; // 팀 주제로 작성 시 자동 설정 visibility?: 'public' | 'team' | 'private'; // 🆕 공개 범위 (기본: private) // 🆕 AI 분석 결과 (저장 시 자동 생성) analysis?: WritingAnalysis; // AI 분석 결과 (선택적) // 🆕 AI 도움 이력 aiAssistanceHistory?: AIAssistanceRecord[]; // 🆕 AI 생성 이미지 generatedImage?: GeneratedImage; // 🆕 이미지 왜곡 영역 설정 distortionAreas?: DistortionAreaData[]; // 타임스탬프 createdAt: Timestamp; // 최초 작성일 updatedAt: Timestamp; // 최종 수정일 } ``` **현재 구현**: - ✅ CRUD 기능 완료 (WritingManager) - ✅ AI 분석 결과 저장 시스템 (WritingAnalysis) - ✅ contentHash 기반 재분석 방지 (비용 절감) - ✅ 맞춤법 에러 히스토리 저장 - 🔜 피드백 시스템 (향후 추가) ### 예시 데이터 ```json { "id": "writing_001", "userId": "abc123xyz", "topicId": "topic_daily_001", "title": "내가 좋아하는 계절", "content": "나는 가을을 제일 좋아해요. 왜냐하면...", "wordCount": 120, "charCount": 450, "status": "published", "visibility": "public", "analysis": { "score": 85, "breakdown": { "sensory": 3.5, "emotion": 1.8, "dialogue": 0, "onomatopoeia": 1.2 }, "foundWords": { "sensory": ["파랗다", "시원하다", "향기롭다"], "emotion": ["좋아해요", "기쁘다"], "onomatopoeia": ["살랑살랑", "바스락"] }, "suggestions": ["대화 표현을 추가하면 더욱 생동감 있는 글이 될 거예요!"], "spellingErrors": [ { "original": "좋와해요", "correction": "좋아해요", "reason": "동사 '좋아하다'의 활용형이에요" } ], "analyzedAt": "2024-10-27T15:00:00Z", "contentHash": "a3b4c5d6e7f8..." }, "createdAt": "2024-10-27T14:00:00Z", "updatedAt": "2024-10-27T15:00:00Z" } ``` ### 인덱스 - `userId` + `createdAt` (복합 인덱스, 내림차순) - `topicId` + `createdAt` (복합 인덱스, 내림차순) - `status` + `createdAt` (복합 인덱스) - `teamId` + `visibility` (복합 인덱스, 팀 공개 글 조회용) --- ## 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. Comment (댓글) 🆕 **컬렉션**: `comments/{commentId}` ### 스키마 ```typescript interface CommentReactions { like: number; // 좋아요 👍 love: number; // 최고예요 ❤️ smile: number; // 웃겨요 😂 clap: number; // 멋져요 👏 } interface Comment { id: string; // 문서 ID writingId: string; // 글 ID userId: string; // 작성자 UID content: string; // 댓글 내용 (최대 500자) parentId: string | null; // 대댓글인 경우 부모 댓글 ID reactions: CommentReactions; // 반응 카운트 // 타임스탬프 createdAt: Timestamp; updatedAt: Timestamp; isDeleted: boolean; // Soft delete } ``` **특징**: - 계층형 구조 지원 (1단계 대댓글) - Soft delete (삭제된 댓글입니다 표시) - 반응형 데이터 (좋아요 등) ### 인덱스 - `writingId` + `createdAt` (복합 인덱스) --- ## 7. UserReaction (사용자 반응) 🆕 **컬렉션**: `userReactions/{reactionId}` ### 스키마 ```typescript interface UserReaction { id: string; // 문서 ID (commentId_userId) commentId: string; // 댓글 ID userId: string; // 사용자 UID type: 'like' | 'love' | 'smile' | 'clap'; // 반응 타입 createdAt: Timestamp; } ``` **특징**: - 사용자당 댓글 1개에 1개의 반응만 가능 (토글 방식) - ID를 `commentId_userId`로 설정하여 중복 방지 --- ## 8. Lesson (학습 레슨) ✅ **컬렉션**: `lessons/{lessonId}` (✅ Fork Model로 개편) ### 스키마 ```typescript interface LessonContent { type: 'theory' | 'mission' | 'quiz' | 'writing_prompt'; data: TheoryBlock | MissionBlock | QuizBlock | WritingPromptBlock; } interface TheoryBlock { markdown: string; } interface MissionBlock { description: string; items: string[]; // 찾아야 할 대상 등 } interface QuizBlock { question: string; options?: string[]; // 객관식 answer: string | number; explanation: string; } interface WritingPromptBlock { prompt: string; guideLines: string[]; } type LessonVisibility = 'public' | 'private' | 'system'; interface Lesson { id: string; // 문서 ID title: string; // 레슨 제목 description: string; // 레슨 설명 // 🆕 Fork Model (2025-12-19) ownerId: string; // 사용자 UID (소유자) visibility: LessonVisibility; // 공개 범위 isSystem: boolean; // 시스템 레슨 여부 forkedFrom?: string; // 원본 레슨 ID (Fork한 경우) forkCount: number; // Fork 횟수 // 분류 level: number; // 추천 레벨 (1-100) category: string; // 카테고리 (예: "문단 쓰기", "비유 표현") // 커리큘럼 연결 curriculumId?: string; orderIndex: number; // 콘텐츠 (유연한 블록 구조) contents: LessonContent[]; // 보상 reward: { experience: number; stickers?: string[]; }; // 통계 completionCount: number; // 타임스탬프 createdAt: Timestamp; updatedAt: Timestamp; createdBy: string; isActive: boolean; } ``` ### 예시 데이터 ```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 } ``` ### 인덱스 - `curriculumId` + `orderIndex` (복합 인덱스, 오름차순) -- 커리큘럼별 조회 - `ownerId` + `createdAt` (복합 인덱스) -- 사용자별 레슨 조회 - `isSystem` + `createdAt` (복합 인덱스) -- 시스템 레슨 조회 --- ## 9. Curriculum (커리큘럼) ✅ **컬렉션**: `curriculums/{curriculumId}` (✅ Fork Model로 개편) ### 스키마 ```typescript type CurriculumVisibility = 'public' | 'private' | 'system'; interface Curriculum { id: string; title: string; description: string; // 🆕 Fork Model (2025-12-19) ownerId: string; // 사용자 UID (소유자) visibility: CurriculumVisibility; // 공개 범위 isSystem: boolean; // 시스템 커리큘럼 여부 forkedFrom?: string; // 원본 커리큘럼 ID (Fork한 경우) forkCount: number; // Fork 횟수 (인기도) // 커리큘럼 구성 아이템 (순서 보장) items: Array<{ type: 'lesson' | 'topic'; id: string; // lessonId or topicId }>; // 메타데이터 createdAt: Timestamp; updatedAt: Timestamp; createdBy: string; isActive: boolean; } ``` ### Fork Model 워크플로우 1. **시스템 커리큘럼**: `isSystem: true`, `visibility: 'system'` 2. **공개 커리큘럼**: `visibility: 'public'` (모든 사용자가 Fork 가능) 3. **Fork 시**: 원본 `forkCount` 증가, 새 커리큘럼에 `forkedFrom` 설정 4. **레슨도 함께 복사**: Fork 시 연결된 레슨도 사용자 소유로 복사 ### 예시 데이터 ```json { "id": "curr_magic_art", "title": "마법의 미술 시간", "description": "글로 그림을 그리는 마법사가 되어보세요!", "ownerId": "user_abc123", "visibility": "public", "isSystem": false, "forkedFrom": "curr_system_sensory", "forkCount": 15, "items": [ { "type": "lesson", "id": "lesson_adj_01" }, { "type": "topic", "id": "topic_drawing_01" } ], "isActive": true } ``` ### 인덱스 - `ownerId` + `createdAt` (사용자별 커리큘럼 조회) - `visibility` + `subscriberCount` (공개 커리큘럼 인기순) - `isSystem` + `createdAt` (시스템 커리큘럼 조회) ### 2단계 구독 모델 (2026-01 업데이트) **개념**: 커리큘럼 구독은 2단계로 이루어집니다: 1. **사용자 구독 (라이브러리)**: 사용자가 커리큘럼을 개인 라이브러리에 추가 - `users/{userId}.subscribedCurriculumIds[]`에 저장 - `curriculums/{id}.subscriberCount` 증가 (유니크 사용자 수) 2. **팀 활성화**: 라이브러리에 있는 커리큘럼을 팀에서 사용 가능하게 설정 - `teams/{teamId}.activeCurriculumIds[]`에 저장 - 팀 소유자만 활성화 가능 **워크플로우**: ``` [탐색] → [라이브러리에 추가] → [팀에서 사용 활성화] → [팀원들 접근] ``` **관련 필드**: ```typescript // FirestoreUser에 추가 interface FirestoreUser { subscribedCurriculumIds?: string[]; // 구독한 커리큘럼 ID (개인 라이브러리) } // Team 타입 변경 interface Team { activeCurriculumIds?: string[]; // 활성화된 커리큘럼 ID (이전: subscribedCurriculumIds) } // Curriculum interface Curriculum { subscriberCount: number; // 구독자 수 (유니크 사용자 기준) } ``` **API 엔드포인트**: - `POST /api/curriculum/{id}/user-subscribe`: 라이브러리에 추가 - `DELETE /api/curriculum/{id}/user-subscribe`: 라이브러리에서 제거 - `GET /api/curriculum/my-library`: 내 라이브러리 조회 - `POST /api/curriculum/{id}/subscribe`: 팀에 활성화 (기존 API, 레거시 호환) - `DELETE /api/curriculum/{id}/subscribe`: 팀에서 비활성화 --- ## 10. 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` (단일 필드, 내림차순) --- ## 11. 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` (복합 인덱스, 중복 방지용) --- ## 12. WritingSession (실시간 글쓰기 모니터링) 🆕 **데이터베이스**: Firebase Realtime Database (휘발성 데이터) ### Realtime DB 구조 #### 11.1. monitoring (글쓰기 통계) **경로**: `monitoring/{teamId}/{topicId}/{userId}` ```typescript interface WritingStats { userId: string; // 작성자 UID contentLength: number; // 현재 글자 수 (공백 포함) wordCount: number; // 현재 단어 수 topicId: string; // 현재 작성 중인 주제 ID lastUpdated: number; // 마지막 업데이트 시간 (timestamp) - 항상 변경됨 } interface SpeedDataPoint { speed: number; // 작성 속도 (글자/분) timestamp: number; // 기록 시간 } interface MonitoringData extends WritingStats { displayName: string; // 표시 이름 speedHistory: SpeedDataPoint[]; // 속도 히스토리 (최근 10개) currentSpeed: number; // 현재 속도 (글자/분) isActive: boolean; // 활성 상태 (false면 나간 상태) preview?: string; // 미리보기 (선택적) } ``` **예시 데이터**: ```json { "monitoring": { "team_abc123": { "topic_xyz": { "user_001": { "userId": "user_001", "contentLength": 1500, "wordCount": 300, "topicId": "topic_xyz", "lastUpdated": 1731398400000 }, "user_002": { "userId": "user_002", "contentLength": 800, "wordCount": 160, "topicId": "topic_xyz", "lastUpdated": 1731398395000 } } } } } ``` **클라이언트 측 계산** (선생님 화면): ```typescript // MonitoringData (UI용) { userId: "user_001", contentLength: 1500, wordCount: 300, topicId: "topic_xyz", lastUpdated: 1731398400000, displayName: "철수", currentSpeed: 420, // 글자/분 (클라이언트 계산) isActive: true, // 활성 상태 (30초 타임아웃 체크) speedHistory: [ // 최근 10개 (클라이언트 계산) { speed: 480, timestamp: 1731398370000 }, { speed: 420, timestamp: 1731398375000 }, { speed: 360, timestamp: 1731398380000 }, { speed: 0, timestamp: 1731398385000 }, // 멈춤! { speed: 0, timestamp: 1731398390000 }, { speed: 420, timestamp: 1731398395000 }, // 재시작 { speed: 480, timestamp: 1731398400000 } ] } ``` **활성 상태 판단 로직**: ```typescript // 1. Firebase에서 데이터 수신 → isActive: true // 2. 30초 타임아웃 체크 const timeSinceUpdate = Date.now() - lastUpdated; if (timeSinceUpdate > 30000) { isActive = false; // 30초 이상 업데이트 없음 → 나감 } // 3. Firebase에서 삭제되어도 마지막 통계 유지 if (!newData[userId] && prevData[userId]) { keep prevData[userId] with isActive: false; } ``` #### 11.2. previewRequests (미리보기 요청) **경로**: `previewRequests/{userId}/{requestId}` ```typescript interface PreviewRequest { requestedBy: string; // 요청한 관리자 UID timestamp: number; // 요청 시간 requestId: string; // 고유 요청 ID } ``` #### 11.3. previewResponses (미리보기 응답) **경로**: `previewResponses/{requestId}` ```typescript interface PreviewResponse { content: string; // 현재 작성 중인 글 내용 timestamp: number; // 응답 시간 requestId: string; // 요청 ID (매칭용) } ``` ### 업데이트 주기 - **통계 전송**: 5초마다 (학생이 팀 주제로 작성 중일 때만) - **자동 정리**: `onDisconnect().remove()`로 페이지 이탈 시 자동 삭제 - **미리보기**: 요청 시에만 (10초 타임아웃) ### Security Rules **파일**: `database.rules.json` ```json { "rules": { "monitoring": { "$teamId": { "$topicId": { "$userId": { ".read": "auth != null && (auth.uid == $userId || root.child('teamOwners').child($teamId).val() == auth.uid)", ".write": "auth != null && auth.uid == $userId" } } } }, "previewRequests": { "$userId": { ".read": "auth != null && auth.uid == $userId", ".write": "auth != null" } }, "previewResponses": { "$requestId": { ".read": "auth != null", ".write": "auth != null" } } } } ``` **권한**: - 통계 쓰기: 본인만 - 통계 읽기: 본인 + 팀 소유자 - 미리보기 요청: 누구나 쓰기, 대상자만 읽기 - 미리보기 응답: 누구나 읽기/쓰기 (requestId로 필터링) **TypeScript**: `src/types/writingSession.ts` --- ## TypeScript 타입 정의 파일 ### 데이터 모델 타입 모든 데이터 모델 타입은 `src/types/` 디렉토리에 정의됩니다. ``` src/types/ ├── team.ts # ✅ Team 데이터 모델 ├── firestoreUser.ts # ✅ User 데이터 모델 (FirestoreUser, User 분리) ├── writing.ts # ✅ Writing 데이터 모델 ├── topic.ts # ✅ Topic 데이터 모델 ├── draft.ts # ✅ Draft 데이터 모델 (글조각) ├── comment.ts # 🆕 Comment 데이터 모델 ├── writingPattern.ts # ✅ WritingPattern 분석 데이터 모델 ├── writingSession.ts # 🆕 WritingSession 실시간 모니터링 타입 ├── lesson.ts # 🔜 Lesson 관련 타입 (예정) ├── sticker.ts # 🔜 Sticker 관련 타입 (예정) └── api/ # ✅ API Request/Response 타입 ├── team.ts # Team API 타입 ├── user.ts # User API 타입 ├── writing.ts # Writing API 타입 ├── comment.ts # Comment API 타입 └── topic.ts # Topic API 타입 ``` ### 사용 예시 ```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 /comments/{commentId} { allow read: if request.auth != null; allow write: if false; } // 주제: 모든 인증된 사용자가 읽을 수 있음 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 직접 접근 안 함) --- ## 3.9. Curriculum (커리큘럼) - Fork Model **컬렉션**: `curriculums` ```typescript type CurriculumVisibility = 'public' | 'private' | 'system'; interface Curriculum { id: string; title: string; description: string; // 🆕 Fork Model (2025-12-19) ownerId: string; // 사용자 UID (소유자) visibility: CurriculumVisibility; // 공개 범위 isSystem: boolean; // 시스템 커리큘럼 여부 forkedFrom?: string; // 원본 커리큘럼 ID (Fork한 경우) forkCount: number; // Fork 횟수 (인기도) // 구성 항목 items: Array<{ type: 'lesson'; id: string; title: string; }>; // 메타데이터 createdAt: Timestamp; updatedAt: Timestamp; createdBy: string; isActive: boolean; } ``` **설명**: - `ownerId`: 커리큘럼 소유자 UID - `visibility`: 공개 범위 (public/private/system) - `isSystem`: 시스템 제공 커리큘럼 여부 - `forkedFrom`: Fork한 원본 커리큘럼 ID - `forkCount`: 이 커리큘럼이 Fork된 횟수 --- ## 3.10. Lesson (레슨) - Fork Model **컬렉션**: `lessons` ```typescript // 레슨 콘텐츠 타입 type LessonContentType = 'theory' | 'mission' | 'quiz' | 'writing_prompt'; type LessonVisibility = 'public' | 'private' | 'system'; // 1. 이론 설명 블록 interface TheoryBlock { markdown: string; imageUrl?: string; } // 2. 미션 블록 interface MissionBlock { description: string; items: string[]; } // 3. 퀴즈 블록 interface QuizBlock { question: string; type: 'multiple_choice' | 'short_answer'; options?: string[]; answer: string | number; explanation: string; } // 4. 글쓰기 프롬프트 블록 interface WritingPromptBlock { prompt: string; guideLines: string[]; minLength?: number; } // 레슨 콘텐츠 아이템 interface LessonContent { type: LessonContentType; data: TheoryBlock | MissionBlock | QuizBlock | WritingPromptBlock; } interface Lesson { id: string; title: string; description: string; // 🆕 Fork Model (2025-12-19) ownerId: string; // 사용자 UID (소유자) visibility: LessonVisibility; // 공개 범위 isSystem: boolean; // 시스템 레슨 여부 forkedFrom?: string; // 원본 레슨 ID (Fork한 경우) forkCount: number; // Fork 횟수 // 분류 level: number; // 1~100 category: string; // "감각", "이야기", "감정" 등 // 커리큘럼 연결 (선택적) curriculumId?: string; orderIndex: number; // 콘텐츠 (순서대로 렌더링) contents: LessonContent[]; // 보상 reward: { experience: number; stickers?: string[]; }; // 메타데이터 completionCount: number; createdAt: Timestamp; updatedAt: Timestamp; createdBy: string; isActive: boolean; } ``` **설명**: - `contents`: 다양한 콘텐츠 블록을 순서대로 배치 (이론 → 미션 → 퀴즈 → 글쓰기) - `ownerId`: 레슨 소유자 UID - `visibility`: 공개 범위 (public/private/system) - `forkedFrom`: Fork한 원본 레슨 ID - `completionCount`: 완료한 학생 수 **4가지 콘텐츠 타입**: 1. **theory**: Markdown 기반 이론 설명 2. **mission**: 활동 지시 (체크리스트) 3. **quiz**: 객관식/단답형 퀴즈 4. **writing_prompt**: 가벼운 글쓰기 주제 --- ## 관련 문서 - [API_SPEC.md](./API_SPEC.md) - API 명세서 (43개 엔드포인트) - [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.