RaonNuri_Public_Documents/DATA_MODELS.md
2025-11-28 06:46:42 +00:00

32 KiB

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

최종 업데이트: 2025-11-28 (댓글 시스템 추가)

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

참고: API 타입 정의는 API_SPEC.md를 참조하세요.


데이터베이스 구조

Firestore (영구 데이터)

firestore
├── teams/                    # ✅ 팀 (팀 코드 시스템)
├── users/                    # ✅ 사용자 프로필 및 메타데이터
├── writings/                 # ✅ 작성한 글
├── topics/                   # ✅ 글쓰기 주제
├── comments/                 # 🆕 댓글 (계층 구조 지원)
├── userReactions/            # 🆕 댓글 반응
├── 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 예약)

스키마:

interface TeamCodeReservation {
  userId: string;      // 예약한 사용자 UID
  createdAt: number;   // 예약 시각 (timestamp)
  expiresAt: number;   // 만료 시각 (createdAt + 5분)
  locale?: string;     // 생성 언어 (ko, en, ja)
}

예시 데이터:

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

스키마

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 닫힌 팀 신규 가입 차단 졸업반, 종료된 프로젝트

예시 데이터

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

스키마

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

예시 데이터

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

스키마

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;      // 마지막 글 작성 시간
}

예시 데이터

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

스키마

// 맞춤법 오류 정보
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} (구현 예정)

스키마

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;           // 해설
}

예시 데이터

{
  "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 (복합 인덱스, 오름차순)

9. 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 (단일 필드, 내림차순)

10. 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 (복합 인덱스, 중복 방지용)

11. 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 직접 접근 안 함)

관련 문서


© 2024 BlueNovaLab. All rights reserved.