RaonNuri_Public_Documents/DATA_MODELS.md
2025-11-26 01:25:48 +00:00

1102 lines
29 KiB
Markdown

# 라온누리 - 데이터 모델 및 스키마
> 최종 업데이트: 2025-11-18 (팀 코드 예약 시스템 + 다국어 생성)
이 문서는 Firestore 데이터베이스 및 Firebase Realtime Database 구조와 TypeScript 타입 정의를 설명합니다.
**참고**: API 타입 정의는 [API_SPEC.md](./API_SPEC.md)를 참조하세요.
---
## 데이터베이스 구조
### Firestore (영구 데이터)
```
firestore
├── teams/ # ✅ 팀 (팀 코드 시스템)
├── users/ # ✅ 사용자 프로필 및 메타데이터
├── writings/ # ✅ 작성한 글
├── topics/ # ✅ 글쓰기 주제
├── patternAnalyses/ # ✅ 패턴 분석 결과 (contentHash 캐싱)
├── lessons/ # 🔜 학습 레슨
├── 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 (정식 계정)
// 보안 설정
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 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)
// 🆕 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",
"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` (복합 인덱스)
---
## 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` (복합 인덱스, 중복 방지용)
---
## 9. WritingSession (실시간 글쓰기 모니터링) 🆕
**데이터베이스**: Firebase Realtime Database (휘발성 데이터)
### Realtime DB 구조
#### 9.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;
}
```
#### 9.2. previewRequests (미리보기 요청)
**경로**: `previewRequests/{userId}/{requestId}`
```typescript
interface PreviewRequest {
requestedBy: string; // 요청한 관리자 UID
timestamp: number; // 요청 시간
requestId: string; // 고유 요청 ID
}
```
#### 9.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 데이터 모델 (글조각)
├── 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 타입
└── 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 /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.