RaonNuri_Public_Documents/DATA_MODELS.md
2026-03-27 02:23:59 +00:00

37 KiB

라온누리 - 데이터 모델 및 스키마

이 문서는 Firestore 데이터베이스 및 Firebase Realtime Database 구조와 TypeScript 타입 정의를 설명합니다.

참고: API 타입 정의는 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}

스키마

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;              // 활성 상태
}

예시 데이터

{
  "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}

설계 원칙

  • Firebase Auth = Single Source of Truth: 이름(displayName), 이메일(email), 프로필 사진(photoURL)은 Firebase Auth에서 관리
  • Firestore에는 앱 전용 메타데이터만 저장 (FirestoreUser)
  • UI User 객체: Firebase Auth + Firestore 자동 결합 (User extends Partial<FirestoreUser>)

FirestoreUser 스키마 (Firestore 저장용)

interface FirestoreUser {
  uid: string;                    // Firebase Auth UID (문서 ID와 동일)

  // 타임스탬프
  createdAt: Timestamp;           // 가입일
  lastLoginAt: Timestamp;         // 마지막 로그인 시간

  // 역할
  role?: UserRole;                // 'student' | 'teacher' (기본값: 'student')
  teacherApproval?: TeacherApproval; // 교사 역할인 경우 승인 정보

  // 설정
  settings?: {
    theme?: "light" | "dark";
    language?: string;
    weeklyGoal?: {
      count: number;              // 1-10, 기본값: 3
      updatedAt: Timestamp;
    };
  };

  // 플랜 및 크레딧
  plan?: UserPlan;                // 사용자 플랜 (기본값: FREE)
  organizationId?: string;        // 소속 조직 ID (School Plan)
  aiCredits?: number;             // AI 크레딧 잔액

  // 커리큘럼
  subscribedCurriculumIds?: string[]; // 구독한 커리큘럼 ID 배열

  // 관리자
  isAdmin?: boolean;

  // 개인 정보
  birthYear?: number;             // 생년 (나이 계산용, 예: 2015)

  // 시스템 계정 (교사 대리 생성)
  loginId?: string;               // 로그인용 아이디 (시스템 계정만)
  isSystemAccount?: boolean;      // 교사가 대리 생성한 계정
  createdByTeacher?: string;      // 생성한 교사 uid
}

User 스키마 (UI 표시용 — Firebase Auth + Firestore 결합)

interface User extends Partial<FirestoreUser> {
  uid: string;                    // 항상 존재 (Firebase Auth UID)

  // Firebase Auth 전용 필드
  email?: string;
  name?: string;                  // Firebase Auth displayName
  photoURL?: string | null;
}

역할 관련 타입

type UserRole = "student" | "teacher";
type TeacherApprovalStatus = "pending" | "approved" | "rejected";

interface TeacherApproval {
  status: TeacherApprovalStatus;
  requestedAt: Timestamp;
  reviewedAt?: Timestamp;         // 승인/거절 시간
  reviewedBy?: string;            // 승인/거절한 관리자 uid
  rejectionReason?: string;       // 거절 사유
}

예시 데이터

{
  "uid": "abc123xyz",
  "createdAt": "2024-10-28T00:00:00Z",
  "lastLoginAt": "2024-10-28T10:00:00Z",
  "role": "student",
  "birthYear": 2015,
  "settings": {
    "language": "ko",
    "weeklyGoal": {
      "count": 3,
      "updatedAt": "2024-11-01T00:00:00Z"
    }
  }
}

인덱스

  • uid (문서 ID)
  • role (단일 필드)
  • loginId (단일 필드, 시스템 계정 조회용)

4. Writing (작성글)

컬렉션: writings/{writingId}

스키마

// 맞춤법 오류 정보
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 기반 재분석 방지 (비용 절감)
  • 맞춤법 에러 히스토리 저장
  • 🔜 피드백 시스템 (향후 추가)

예시 데이터

{
  "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}

스키마

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 특별한 경험 "처음 해본 것", "여행에서의 추억"

예시 데이터

{
  "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}

스키마

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}

스키마

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로 개편)

스키마

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;
}

예시 데이터

{
  "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로 개편)

스키마

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 시 연결된 레슨도 사용자 소유로 복사

예시 데이터

{
  "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[]에 저장
    • 팀 소유자만 활성화 가능

워크플로우:

[탐색] → [라이브러리에 추가] → [팀에서 사용 활성화] → [팀원들 접근]

관련 필드:

// 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} (구현 예정)

스키마

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 특별 이벤트

예시 데이터

{
  "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} (구현 예정)

스키마

interface UserSticker {
  id: string;                     // 문서 ID
  userId: string;                 // 사용자 UID
  stickerId: string;              // 스티커 ID
  unlockedAt: Timestamp;          // 획득 시간
}

예시 데이터

{
  "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}

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;              // 미리보기 (선택적)
}

예시 데이터:

{
  "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
        }
      }
    }
  }
}

클라이언트 측 계산 (선생님 화면):

// 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 }
  ]
}

활성 상태 판단 로직:

// 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}

interface PreviewRequest {
  requestedBy: string;      // 요청한 관리자 UID
  timestamp: number;        // 요청 시간
  requestId: string;        // 고유 요청 ID
}

11.3. previewResponses (미리보기 응답)

경로: previewResponses/{requestId}

interface PreviewResponse {
  content: string;          // 현재 작성 중인 글 내용
  timestamp: number;        // 응답 시간
  requestId: string;        // 요청 ID (매칭용)
}

업데이트 주기

  • 통계 전송: 5초마다 (학생이 팀 주제로 작성 중일 때만)
  • 자동 정리: onDisconnect().remove()로 페이지 이탈 시 자동 삭제
  • 미리보기: 요청 시에만 (10초 타임아웃)

Security Rules

파일: database.rules.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 타입

사용 예시

// 데이터 모델 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에 정의된 공통 타입:

// 표준 응답 형식
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에서 권한 체크 수행

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

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

// 레슨 콘텐츠 타입
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: 가벼운 글쓰기 주제

관련 문서


© 2024 BlueNovaLab. All rights reserved.