1433 lines
38 KiB
Markdown
1433 lines
38 KiB
Markdown
# 라온누리 - 데이터 모델 및 스키마
|
|
|
|
이 문서는 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}/
|
|
└── teamCodeReservations/ # 🆕 팀 코드 예약 (Race Condition 방지)
|
|
└── {code-with-hyphens}/ # 예: "춤추는-파란-사자"
|
|
```
|
|
|
|
**범례**:
|
|
- ✅ 구현 완료
|
|
- 🔜 구현 예정
|
|
|
|
---
|
|
|
|
## Realtime Database 데이터 모델
|
|
|
|
### teamCodeReservations (팀 코드 예약) ✅
|
|
|
|
**경로**: `teamCodeReservations/{code-with-hyphens}`
|
|
|
|
**목적**: 팀 코드 생성 시 Race Condition 방지 (Atomic 예약)
|
|
|
|
**스키마**:
|
|
```typescript
|
|
interface TeamCodeReservation {
|
|
userId: string; // 예약한 사용자 UID
|
|
createdAt: number; // 예약 시각 (timestamp)
|
|
expiresAt: number; // 만료 시각 (createdAt + 5분)
|
|
locale?: string; // 생성 언어 (ko, en, ja)
|
|
}
|
|
```
|
|
|
|
**예시 데이터**:
|
|
```json
|
|
{
|
|
"teamCodeReservations": {
|
|
"춤추는-파란-사자": {
|
|
"userId": "abc123...",
|
|
"createdAt": 1700000000000,
|
|
"expiresAt": 1700000300000,
|
|
"locale": "ko"
|
|
},
|
|
"dancing-blue-lion": {
|
|
"userId": "def456...",
|
|
"createdAt": 1700000050000,
|
|
"expiresAt": 1700000350000,
|
|
"locale": "en"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**특징**:
|
|
- ✅ **Atomic 예약**: Transaction으로 동시 요청 처리
|
|
- ✅ **5분 TTL**: 자동 만료 (팀 생성 안 하면 해제)
|
|
- ✅ **자동 정리**: cleanupExpiredReservations() 함수
|
|
- ✅ **언어별 코드**: ko/en/ja 각각 다른 단어 사용
|
|
|
|
**Security Rules**:
|
|
- `.read`: 모두 허용 (중복 체크)
|
|
- `.write`: 본인만 수정 가능
|
|
- `.validate`: userId, createdAt, expiresAt 필수 + TTL 검증
|
|
|
|
---
|
|
|
|
## 1. Team (팀) ✅
|
|
|
|
**컬렉션**: `teams/{teamId}`
|
|
|
|
### 스키마
|
|
|
|
```typescript
|
|
interface Team {
|
|
id: string; // 문서 ID
|
|
code: string; // 팀 코드 (예: "춤추는 파란 사자")
|
|
name: string; // 팀 이름 (예: "2학년 1반")
|
|
ownerId: string; // 팀 소유자 UID
|
|
|
|
// 보안 레벨 (5단계 시스템)
|
|
securityLevel: TeamSecurityLevel; // 1~5 (OPEN, NAME_LIST, AUTH_REQUIRED, EMAIL_LIST, CLOSED)
|
|
|
|
// 명단 관리
|
|
allowedNames?: string[]; // Level 2용: 허용된 이름 목록
|
|
allowedEmails?: string[]; // Level 4용: 허용된 이메일 목록
|
|
|
|
// 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; // 활성 상태
|
|
}
|
|
```
|
|
|
|
### 보안 레벨 (5단계)
|
|
|
|
| Level | Enum | 이름 | 익명 허용 | 가입 제한 | 주요 사용처 |
|
|
|-------|------|------|-----------|-----------|------------|
|
|
| **1** | `OPEN` | 완전 개방 | ✅ | 닉네임 공유 로그인 | 공개 워크샵, 체험 수업 |
|
|
| **2** | `NAME_LIST` | 명단 기반 | ✅ | `allowedNames` 체크 | 저학년 반 (익명이지만 통제) |
|
|
| **3** | `AUTH_REQUIRED` | 로그인 필수 | ❌ | 정식 계정 누구나 | 고학년 반 (구글 계정) ⭐ 추천 |
|
|
| **4** | `EMAIL_LIST` | 이메일 제한 | ❌ | `allowedEmails` 체크 | 특정 학생만 (전학생 차단) |
|
|
| **5** | `CLOSED` | 닫힌 팀 | ❌ | 신규 가입 차단 | 졸업반, 종료된 프로젝트 |
|
|
|
|
### 예시 데이터
|
|
|
|
```json
|
|
{
|
|
"id": "team_abc123",
|
|
"code": "춤추는 파란 사자",
|
|
"name": "2학년 1반",
|
|
"ownerId": "user_xyz",
|
|
|
|
"securityLevel": 3,
|
|
"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. 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 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` + `forkCount` (공개 커리큘럼 인기순)
|
|
- `isSystem` + `createdAt` (시스템 커리큘럼 조회)
|
|
|
|
---
|
|
|
|
## 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<T> {
|
|
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.
|