2026-08-11 05:50:34 +00:00

62 KiB
Raw Blame History

API Specification

라온누리 서버 API 명세서


🔧 API 개발 필수 가이드

RESTful API 설계 원칙

HTTP Method로 동작 구분 (경로로 구분하지 않음):

// ✅ 올바른 방식 (RESTful)
POST   /api/team/:id/members     // 멤버 추가
DELETE /api/team/:id/members     // 멤버 제거

// ❌ 잘못된 방식 (경로로 구분)
POST /api/team/:id/members/add     // ❌
POST /api/team/:id/members/remove  // ❌

API Route 파일 구조:

// src/app/api/team/[teamId]/members/route.ts

export async function POST(request: NextRequest, context: RouteContext) {
  // 추가 로직
}

export async function DELETE(request: NextRequest, context: RouteContext) {
  // 삭제 로직
}

HTTP Method 사용 가이드:

  • GET: 조회 (캐싱 가능, 멱등성)
  • POST: 생성, 추가, 복잡한 조회
  • PUT: 전체 수정 (멱등성)
  • PATCH: 부분 수정
  • DELETE: 삭제 (멱등성)

Firebase Admin SDK 사용 규칙

절대 사용 금지: getFirestore() 직접 호출 필수 사용: adminFbClient 싱글톤 인스턴스

// ❌ 잘못된 방식 - 초기화 문제 및 인증 오류 발생 가능
import {getFirestore} from "firebase-admin/firestore";
const db = getFirestore();
const doc = await db.collection('users').doc(uid).get();

// ✅ 올바른 방식 - 싱글톤 인스턴스 사용
import {adminFbClient} from "@/lib/firebase-admin";
const doc = await adminFbClient.collection('users').doc(uid).get();

이유:

  • getFirestore()는 매번 호출 시 초기화 상태 불확실
  • adminFbClientsrc/lib/firebase-admin.ts에서 한 번만 초기화
  • 환경변수 FIREBASE_SERVICE_ACCOUNT_KEY를 통해 명시적 인증 보장

API Route 응답 헬퍼 함수 (필수)

모든 API Route는 src/lib/api-response.ts의 헬퍼 함수를 사용해야 합니다.

import {
  successResponse,
  errorResponse,
  unauthorizedResponse,
  forbiddenResponse,
  notFoundResponse,
  validationErrorResponse,
  internalErrorResponse
} from "@/lib/api-response";
import {UnwrapApiResponse} from "@/types/api";

// ✅ 성공 응답 패턴
export async function GET(request: NextRequest) {
  const data = await fetchData();

  const response: UnwrapApiResponse<MyResponse> = {
    items: data,
    totalCount: data.length,
  };
  return successResponse(response);
}

// ✅ 에러 응답 패턴
export async function POST(request: NextRequest) {
  const authHeader = request.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return unauthorizedResponse();  // 401
  }

  const body = await request.json();
  if (!body.title?.trim()) {
    return validationErrorResponse("제목이 비어있습니다");  // 400
  }

  const user = await getUser(body.userId);
  if (!user) {
    return notFoundResponse("사용자를 찾을 수 없습니다");  // 404
  }

  if (user.id !== currentUserId) {
    return forbiddenResponse();  // 403
  }

  try {
    // ... 로직
  } catch (error) {
    return internalErrorResponse();  // 500
  }
}

절대 사용 금지: 수동 응답 객체 생성

// ❌ Bad - 직접 NextResponse.json 사용 금지
return NextResponse.json(
  {success: false, error: "에러 메시지", code: "ERROR_CODE"},
  {status: 400}
);

// ✅ Good - 헬퍼 함수 사용
return validationErrorResponse("에러 메시지");

Manager ApiCall 패턴 (클라이언트)

Manager에서 ApiCall 사용 시 주의사항:

ApiCall은 성공 시 언래핑된 데이터만 반환하고, 실패 시 자동으로 에러를 throw합니다.

import {SingletonManager} from "./ManagerBase";
import {HttpMethod, UnwrapApiResponse} from "@/types/api";
import type {CreateItemRequest, CreateItemResponse} from "@/types/api/item";

class ItemManager extends SingletonManager {
  // ✅ 올바른 방식 - ApiCall은 자동으로 언래핑
  async createItem(data: CreateItemRequest): Promise<Item> {
    const response = await this.ApiCall<CreateItemRequest, CreateItemResponse>(
      HttpMethod.POST,
      '/item',
      data
    );

    // response는 이미 {item: Item} 형태 (언래핑됨)
    return response.item;
  }

  // ❌ 잘못된 방식 - success 체크 불필요
  async createItemWrong(data: CreateItemRequest): Promise<Item> {
    const response = await this.ApiCall<CreateItemRequest, CreateItemResponse>(
      HttpMethod.POST,
      '/item',
      data
    );

    // ❌ response.success는 존재하지 않음 (타입 에러)
    if (!response.success || !response.item) {
      throw new Error("생성 실패");
    }

    return response.item;
  }

  // ✅ 반환값이 없는 경우 (DELETE 등)
  async deleteItem(id: string): Promise<void> {
    await this.ApiCall<null, DeleteItemResponse>(
      HttpMethod.DELETE,
      `/item/${id}`,
      null
    );
    // 성공하면 그대로 종료, 실패하면 자동으로 에러 throw
  }
}

핵심 원칙:

  • ApiCall 반환값 = UnwrapApiResponse<T> (success, error 필드 없음)
  • 에러 처리는 try-catch로 (자동 throw)
  • response.success 체크 코드는 타입 에러 발생

개요

  • Base URL: /api (환경변수 NEXT_PUBLIC_API_URL로 설정 가능, 기본값 /api)
  • 엔드포인트: /team, /user 등 (Base URL에 /api 포함됨)
  • 실제 호출 URL: {BASE_URL}{endpoint}/api/team, /api/user
  • 인증: Firebase ID Token을 Authorization: Bearer {token} 헤더로 전달
  • 응답 형식: 모든 API는 ApiResponse<T> 형식 반환
interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: ApiError;
}

interface ApiError {
  code: string;
  message: string;
  details?: any;
}

Image Generation Check API

POST /api/check-image-generation

설명: 이미지 생성 가능 여부 확인 (팀/개인 글쓰기에 따라 다른 검증)

인증: 필수

Request Body:

{
  writingId: string;  // 글 ID
}

Response:

{
  success: true,
  data: {
    allowed: boolean;          // 이미지 생성 가능 여부
    reason?: ImageGenerationDisableReason;  // 비활성화 사유
    remaining?: number;        // 남은 횟수
    limit?: number;            // 전체 한도 (-1은 무제한)
    isTeamWriting: boolean;    // 팀 글쓰기 여부
  }
}

ImageGenerationDisableReason:

type ImageGenerationDisableReason =
  | "PLAN_NOT_SUPPORTED"      // Pro 플랜 이상 필요
  | "LIMIT_EXCEEDED"          // 개인 월간 한도 초과
  | "TEAM_AI_DISABLED"        // 팀 AI 비활성화
  | "TEAM_LIMIT_EXCEEDED"     // 팀 월간 한도 초과
  | "DAILY_LIMIT_EXCEEDED";   // 팀원 일일 한도 초과

검증 로직:

  1. 팀 글쓰기 (writing.teamId 존재):
    • canTeamUseImageGeneration(teamId, userId) 호출
    • 팀 AI 설정 + 월간/일일 제한 확인
  2. 개인 글쓰기 (writing.teamId 없음):
    • canUseAIFeature(userId, AIFeatureType.IMAGE_GENERATION) 호출
    • 개인 플랜 + 월간 제한 확인

에러:

  • 400: writingId 누락
  • 401: 인증 필요
  • 404: 글을 찾을 수 없음 또는 본인 글이 아님

Manager 사용법:

import { writingManager } from "@/managers";

const result = await writingManager.checkImageGenerationAvailability(writingId);
// → { allowed: true, remaining: 5, limit: 10, isTeamWriting: true }
// → { allowed: false, reason: "TEAM_LIMIT_EXCEEDED", isTeamWriting: true }

캐싱: 없음 (실시간 확인 필요)


Text Analysis API

POST /api/analyze-text

설명: Vertex AI 기반 텍스트 분석 (초등학생 글쓰기 평가)

인증: 선택 (비로그인도 사용 가능)

Request Body:

{
  text: string;           // 분석할 텍스트 (최소 30자)
  previousText?: string;  // 이전 텍스트 (Delta 전송용, 선택)
}

Response:

{
  success: true,
  data: {
    score: number;        // 0~10 점수
    breakdown: {
      sensory: number;        // 감각 동사 점수 (0~4)
      descriptive: number;    // 감각 형용사 점수 (0~3)
      dialogue: number;       // 대화 점수 (0~2)
      onomatopoeia: number;   // 의성어/의태어 점수 (0~1)
    };
    foundWords: {
      sensory: string[];        // 찾은 감각 동사 목록
      descriptive: string[];    // 찾은 형용사 목록
      onomatopoeia: string[];   // 찾은 의성어 목록
    };
    suggestions: string[];  // AI 수정 제안 목록
  }
}

Error Response (429 Rate Limit):

{
  success: false,
  error: {
    code: "RATE_LIMIT",
    message: "Vertex AI 요청 실패 (모든 region 시도 완료)"
  }
}

특징:

  • Delta 전송: previousText 제공 시 변경분만 분석 (토큰 40% 절감)
  • 서버 캐싱: 동일 텍스트 1분간 캐싱 (In-Memory LRU)
  • Multi-Region: 3개 region 자동 전환 (도쿄/싱가포르/미국)
  • Retry: Exponential backoff (최대 3회)
  • Region Health: 과부하 region 1분간 제외

Manager 사용법:

// 직접 API 호출 (서비스 레이어)
import { analyzeText } from "@/services/textAnalysisService";

const result = await analyzeText("오늘 날씨가 좋다.");
// → { score: 2.5, foundWords: {...}, suggestions: [...] }

캐싱 전략:

  • 마지막 100자로 해시 생성
  • TTL: 60초
  • 최대 50개 캐시

비용:

  • Gemini 2.5 Flash: $0.075/1M 입력 토큰
  • 평균 500자 분석: ~$0.0003/회
  • Delta 사용 시: ~$0.00018/회 (40% 절감)

Pattern Analysis API

POST /api/analyze-pattern

설명: 글 작성 패턴 분석 (사용자의 여러 글을 종합 분석)

인증: 필수

Request Body:

{
  analysisType?: "self" | "by-topic" | "by-team";  // 분석 타입 (기본: self)
  targetUserId?: string;   // 분석 대상 유저 (팀원 분석 시)
  topicId?: string;        // 주제 ID (by-topic 시 필수)
  teamId?: string;         // 팀 ID (by-team 시 필수)
  limit?: number;          // 분석할 글의 최대 개수 (기본: 10개)
  contentHash?: string;    // 🆕 클라이언트가 계산한 해시 (캐시 조회용, 선택)
}

Response:

{
  success: true,
  pattern: {
    userId: string;
    analyzedAt: Date;
    totalWritingsAnalyzed: number;

    // 분석 컨텍스트
    analysisType?: "self" | "by-topic" | "by-team";
    targetUserName?: string;
    topicId?: string;
    topicName?: string;
    teamId?: string;
    teamName?: string;

    // 작성 스타일
    writingStyle: {
      averageWordCount: number;
      averageCharCount: number;
      preferredLength: "짧음" | "보통" | "긴편";
      sentenceStructure: "단문 위주" | "복문 위주" | "혼합형";
    };

    // 표현력 분석
    expressionAnalysis: {
      averageScore: number;  // 0~10
      strongPoints: string[];
      weakPoints: string[];
      breakdown: {
        sensory: number;        // 오감 표현 (0~4)
        emotion: number;        // 감정 표현 (0~2)
        dialogue: number;       // 대화 표현 (0~2)
        onomatopoeia: number;   // 의성어/의태어 (0~2)
      };
      frequentExpressions: {
        sensory: string[];
        emotion: string[];
        onomatopoeia: string[];
      };
    };

    // 맞춤법 경향
    spellingTendency: {
      commonErrors: Array<{
        error: string;
        correction: string;
        frequency: number;
      }>;
      improvementRate: number;  // 0~100
    };

    // 발전 추이
    progressTrend: {
      isImproving: boolean;
      scoreChange: number;
      improvementAreas: string[];
      needsAttention: string[];
    };

    // AI 종합 평가
    summary: {
      overallAssessment: string;
      encouragement: string;
      recommendations: string[];
    };
  },
  contentHash: string;  // 🆕 서버가 계산한 해시 (클라이언트 캐싱용)
}

에러:

// 404 - 분석할 글 없음
{
  success: false,
  error: "분석할 글이 없습니다. 글을 작성한 후 다시 시도해주세요."
}

// 403 - 권한 없음 (by-topic, by-team)
{
  success: false,
  error: "팀 소유자만 팀원의 글을 분석할 수 있습니다"
}

분석 타입:

  1. self (본인 분석):

    • 모든 published 글 분석
    • 권한: 본인만
  2. by-topic (주제별 분석):

    • 특정 팀 주제로 작성된 글만 분석
    • 권한: 팀 소유자만
    • 주제가 팀 주제(ownerType="team")여야 함
  3. by-team (팀 전체 분석):

    • 팀의 모든 주제로 작성된 글 분석
    • 권한: 팀 소유자만
    • 팀 외 글(자유 주제, 다른 팀)은 제외

캐싱 전략 (Content Hash 기반 3단계):

  • L1 (Client): localStorage에 contentHash를 키로 저장 (영구, LRU 10개) ~1ms
  • L2 (Firestore): patternAnalyses/{contentHash} 컬렉션에 저장 (영구) ~100ms
  • L3 (Server): In-memory Map에 저장 (5분 TTL, 최대 50개) ~50ms
  • 변경 감지: 글 추가/수정 시 updatedAt 변경 → 해시 변경 → 자동 재분석
  • AI 비용 절감: 동일 글 세트는 전체 사용자 기준 1회만 분석 (Firestore 공유)

Hash 생성 규칙:

// 입력: analysisType | limit | topicId | teamId | id:updatedAt,id:updatedAt,...
// 예시: "self|10|||abc:2024-01-01T00:00:00.000Z,def:2024-01-02T00:00:00.000Z"
// SHA-256 → "abc123def456..."

성능:

  • 캐시 히트 (동일 글 세트): ~1ms (localStorage)
  • 캐시 히트 (서버): ~50ms (in-memory)
  • 캐시 미스: 5~10초 (AI 분석)

Firestore 저장 구조:

patternAnalyses/{contentHash}
  - contentHash: string
  - pattern: WritingPatternAnalysis
  - createdAt: Timestamp

사용 흐름:

// 클라이언트 (self 분석)
1.  목록 조회
2. contentHash 계산
3. L1 확인 (localStorage)  히트  즉시 반환 (~1ms)
4. 서버 요청 (contentHash 포함)
5. 서버 응답  L1에 저장

// 서버
1. 클라이언트 contentHash로 L3 확인 (in-memory)  히트  즉시 반환 (~50ms)
2. 권한 체크   조회
3. 서버 contentHash 계산
4. L3 재확인 (in-memory)  히트  즉시 반환
5. L2 확인 (Firestore)  히트  반환 + L3 저장 (~100ms)
6. 캐시 미스: AI 분석 수행 (5~10)
7. L2 (Firestore) + L3 (in-memory) 저장
8. contentHash 포함하여 응답

AI 비용 절감:

  • 동일한 글 세트는 전체 사용자 기준 1회만 분석 (Firestore 공유)
  • 예: 학생 A가 글 10개 작성 → 학생 B도 같은 글 10개 작성 → B는 AI 분석 없이 Firestore에서 조회

Team API

1. POST /team - 팀 생성

실제 URL: POST /api/team

인증: 필수 (정식 계정)

Request:

{
  name: string;           // 팀 이름
  grade?: number;         // 학년 (1~6) — D17: 팀 생성 시 학년 고정
  curriculumId?: string;  // 커리큘럼 ID (예: "g3-c1") — D16: 팀 생성 시 8주 코스 고정
}

Response:

{
  success: true,
  data: {
    teamId: string;
    team: Team;           // 생성된 팀 객체
  }
}

권한: 현재 로그인한 사용자가 자동으로 팀 소유자가 됨


2. GET /team/:id - 팀 조회

실제 URL: GET /api/team/:id

인증: 선택적 (공개 팀은 인증 없이 조회 가능)

Response:

{
  success: true,
  data: {
    team: Team;
  }
}

캐싱: 클라이언트에서 5분간 캐싱


3. GET /team/list - 내 팀 목록

실제 URL: GET /api/team/list

인증: 필수 (정식 계정)

Response:

{
  success: true,
  data: {
    teams: Team[];
  }
}

권한: 로그인한 사용자가 소유한 팀 + 참여한 팀 모두 조회 (중복 제거)

백엔드 로직:

  1. getTeamsByOwner(uid) - 소유한 팀 조회
  2. getTeamsByMember(uid) - 참여한 팀 조회 (memberUids array-contains)
  3. 두 결과를 병합하고 중복 제거하여 반환

캐싱: 클라이언트에서 1분간 캐싱


5. PUT /team/:id - 팀 정보 수정

실제 URL: PUT /api/team/:id

인증: 필수

Request:

{
  teamId: string;
  data: {
    name?: string;
    grade?: number;         // 학년 (1~6) — D17
    curriculumId?: string;  // 커리큘럼 ID (예: "g3-c1") — D16; grade와 일치해야 함
    securityMode?: "simple" | "normal" | "open";
    requirePin?: boolean;
    allowAnonymousJoin?: boolean;
    isActive?: boolean;
    // 수업 일정은 여기 없다 — 날짜는 회차 문서가 든다 (「TeamSession API (회차)」 참조)
  }
}

Response:

{
  success: true,
  data: {
    team: Team;
  }
}

권한: 팀 소유자만 수정 가능

캐시 무효화: 해당 팀, 팀 목록


6. DELETE /team/:id - 팀 삭제 (Soft Delete)

실제 URL: DELETE /api/team/:id

인증: 필수

Request:

{
  teamId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 팀 소유자만 삭제 가능

캐시 무효화: 해당 팀, 팀 목록


7. POST /team/add-student - 팀에 학생 추가

실제 URL: POST /api/team/add-student

인증: 필수 (내부 사용 - StudentManager에서 호출)

Request:

{
  teamId: string;
  studentId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

캐시 무효화: 해당 팀


8. POST /team/add-member - 공개 팀 self-join

실제 URL: POST /api/team/add-member

인증: 필수 (본인 참여 전용 — body의 uid는 인증된 사용자와 일치해야 하며, 생략 가능)

제약:

  • 공개 팀(isPublic: true)만 참여 가능 — 비공개 팀은 초대 링크로만
  • 비활성 팀(isActive: false) 참여 불가
  • 이미 멤버/소유자면 멱등 성공

Request:

{
  teamId: string;
  uid?: string;       // 선택 — 전달 시 본인 uid여야 함 (타인 추가 불가)
  nickname?: string;  // 닉네임 (선택적)
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

캐시 무효화: 해당 팀


9. POST /team/remove-student - 팀에서 학생 제거

실제 URL: POST /api/team/remove-student

인증: 필수

Request:

{
  teamId: string;
  studentId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 팀 소유자만 제거 가능 (서버에서 검증)

캐시 무효화: 해당 팀


🆕 10. POST /team/remove-member - 팀 멤버 제거/나가기

실제 URL: POST /api/team/remove-member

설명: 팀에서 멤버를 제거합니다. 소유자는 다른 멤버를 강퇴할 수 있고, 일반 멤버는 본인을 제거(팀 나가기)할 수 있습니다.

인증: 필수

Request:

{
  teamId: string;
  uid: string;  // 제거할 멤버의 UID
}

Response:

{
  success: true
}

권한 체크:

  • 팀 소유자: 다른 멤버 강퇴 가능 (자신은 불가)
  • 일반 멤버: 본인만 제거 가능 (팀 나가기)
  • 소유자가 본인을 제거: "팀 소유자는 팀을 나갈 수 없습니다. 팀을 삭제하거나 소유권을 이전해주세요."
  • 일반 멤버가 타인을 제거: "팀을 관리할 권한이 없습니다."

에러:

  • 404: 팀을 찾을 수 없음
  • 403: 권한 없음 (위 권한 체크 참조)

사용 예시:

// 팀 나가기
await teamManager.removeMember(teamId, currentUser.uid);


🆕 17. POST /team/:teamId/cover-image - 팀 커버 이미지 업로드

실제 URL: POST /api/team/:teamId/cover-image

설명: 팀 커버 이미지를 Firebase Storage에 업로드하고 팀 문서를 업데이트합니다.

인증: 필수 (팀 소유자만)

Request:

// FormData 형식
{
  file: File;  // 이미지 파일 (JPEG/PNG/WebP/GIF, 최대 5MB)
}

Response:

{
  success: true,
  data: {
    coverImageUrl: string;  // 업로드된 이미지의 공개 URL
  }
}

처리 로직:

  1. 파일 타입 검증 (JPEG/PNG/WebP/GIF만 허용)
  2. 파일 크기 검증 (5MB 이하)
  3. Firebase Storage 업로드 (teams/{teamId}/cover-{timestamp}.{ext})
  4. 파일 공개 설정 (makePublic())
  5. 기존 이미지가 있으면 Storage에서 삭제
  6. 팀 문서 coverImage 필드 업데이트

캐시 무효화: 해당 팀, 공개 팀 목록


🆕 18. DELETE /team/:teamId/cover-image - 팀 커버 이미지 삭제

실제 URL: DELETE /api/team/:teamId/cover-image

설명: 팀 커버 이미지를 Storage에서 삭제하고 팀 문서를 업데이트합니다.

인증: 필수 (팀 소유자만)

Response:

{
  success: true
}

처리 로직:

  1. 팀 문서에서 coverImage URL 조회
  2. Storage에서 파일 삭제
  3. 팀 문서 coverImage 필드 제거

캐시 무효화: 해당 팀, 공개 팀 목록


🆕 19. GET /team/:teamId/attendance - 팀 출결관리 탭 데이터 조회

실제 URL: GET /api/team/:teamId/attendance

인증: 필수 (팀 소유자만)

설명: 출석의 유일한 진실은 teamSessions/{sessionId}/checkins 서브컬렉션이다 (src/lib/server/teamSession.ts). 이 라우트는 회차+체크인 데이터를 응답 DTO 형태로 집계만 한다 (src/lib/server/attendance.ts).

"최근 수업"은 관측된 회차만이다. 개설 시점에 8주치 회차를 미리 만들어 두므로 그냥 date DESC로 뽑으면 목록 앞쪽이 통째로 아직 하지 않은 미래 회차다. 그래서 getObservedSessionsin_progress·done만 본다. 관측된 회차가 하나도 없으면 다음 예정 회차로 "언제 첫 수업인가"에 답하고, 그것마저 없을 때만 hasSchedule: false다.

Response:

{
  success: true,
  data: {
    hasSchedule: boolean;
    classTime?: string;        // "HH:mm"
    membersTotal: number;      // 팀 소유자 제외 인원수
    date?: string;             // "YYYY-MM-DD" (오늘 또는 가장 최근 수업일, KST)
    present: number;
    late: number;
    rows: Array<{ uid: string; name: string; time: string; isLate: boolean }>; // 출석(지각 포함)한 팀원만
    history: Array<{ date: string; membersTotal: number; present: number; late: number }>; // 최근 수업일 최대 5개 (최신순)
  }
}

백엔드 로직:

  1. getTeam(teamId) 조회 후 소유자 권한 체크
  2. getObservedSessions(teamId, 5)in_progress·done 회차를 date 내림차순 최대 5개
  3. 하나도 없으면 getNextScheduledSession(teamId)로 다음 예정 회차의 시각만 실어 보낸다
  4. 가장 최근 회차가 오늘의 출결, 전체 목록이 이력 — buildAttendanceSummary로 회차+체크인을 응답 DTO로 집계

캐싱: 클라이언트에서 1분간 캐싱 (teamManager.getTeamAttendance(teamId))

Firestore 인덱스: teamSessions(teamId, status, date DESC) 복합 인덱스 필요 (firestore.indexes.json 참조)


🆕 20. POST /team/:teamId/session/checkin - 학생 체크인

실제 URL: POST /api/team/:teamId/session/checkin

인증: 필수 (팀 멤버만, uid in team.members)

Request:

{
  sessionId: string;   // GET /session/current 이 돌려준 회차 ID
}

설명: 대기실 도착 시 호출하는 체크인. 멱등 — 재호출·URL 직접 접근 시에도 최초 체크인 레코드를 그대로 반환한다.

이 라우트에는 오프너가 없다. 회차를 여는 것은 GET /session/current이고, 클라이언트는 거기서 받은 ID로 여기 온다. 여기서 한 번 더 열면 같은 전이 규칙이 두 경로에 복제되고, 복제된 규칙이 어긋나는 것이 이 모델을 다시 만든 원래 병이다. 그래서 이 라우트는 검증만 한다.

서버는 클라이언트가 보낸 sessionId를 믿지 않는다. 그 ID는 쉽게 낡는다 — 교사가 휴강으로 날짜를 옮기면 학생이 들고 있던 ID는 어제 회차를 가리킨 채 여전히 유효한 문서다. 그대로 두면 출석이 조용히 다른 회차에 기록된다. 그래서 세 가지를 다시 확인하고, 하나라도 어긋나면 409다:

검증 어긋났을 때
회차의 teamId가 경로의 팀과 같은가 409 "이 반의 회차가 아닙니다." — 경로 권한만 보고 통과시키면 남의 반 출석부에 이름이 남는다
status === "in_progress"인가 409 "아직 시작하지 않았거나 이미 끝난 수업입니다." — 문서 존재는 열림을 뜻하지 않는다
회차 날짜가 오늘(KST)인가 (assertSessionIsToday) 409 "오늘의 수업이 아닙니다. 화면을 새로고침해 주세요."

셋 다 console.warn으로 흔적을 남긴다 — 조용한 오기록보다 시끄러운 거절이 낫다.

Response:

{
  success: true,
  data: {
    session: TeamSession;
    myCheckIn: SessionCheckIn; // { uid, arrivedAt, isLate }
  }
}

에러: 400(sessionId 누락), 403(팀 멤버 아님·비활성 팀), 404(팀/회차 없음), 409(위 3종)

Manager: teamSessionManager.checkIn(teamId, sessionId) — 성공 시 입장 카드 캐시를 즉시 무효화


🆕 21. GET /team/:teamId/session/current - 수업 대기실 데이터 조회

실제 URL: GET /api/team/:teamId/session/current

인증: 필수 (팀 멤버만)

설명: 대기실 화면용 데이터. 의도적 GET write-throughgetInProgressSession이 전이를 수행하므로 이 조회 자체가 오프너다. 크론이 없어 수업을 실제로 여는 것은 이런 요청 경로뿐이고, 학생 체크인은 여기서 받은 ID로 이뤄지므로 이 라우트가 대기실의 오프너 단일 지점이다.

⚠️ session: null은 "오늘 회차 문서가 없다"가 아니라 "지금 진행 중인 회차가 없다"다. 개설 시점에 8주치 회차를 만들어 두므로 문서 존재는 아무것도 말해 주지 않는다.

Response:

{
  success: true,
  data: {
    session: TeamSession | null;
    checkIns: Array<SessionCheckIn & { name: string }>; // 팀 닉네임 포함
    myCheckIn: SessionCheckIn | null;
  }
}

에러: 403(팀 멤버 아님), 404(팀 없음), 409(진행 중 회차가 둘 이상 — 하나를 조용히 고르면 출석·글이 틀린 회차에 영구 기록되므로 고르지 않고 거부한다)

캐싱: 없음 (근실시간성 필요)


TeamSession API (회차)

수업 1회가 문서 1건이다(teamSessions/{randomId}). 그 문서가 날짜·시각·주차·글감·글작성 단계·진행 상태·출석을 전부 들기 때문에 "오늘 무슨 수업인가"는 계산이 아니라 조회 한 줄이고, 교사 표와 학생 알림장이 같은 행을 읽는다. 데이터 모델은 DATA_MODELS.md의 「TeamSession」 절을 참조.

구 배정 API(/team/:teamId/assignment 4종)와 구 세션 API(POST /team/:teamId/session)는 이 API로 대체되어 제거되었다. assignments·classSessions 컬렉션도 함께 삭제됐다.

세 가지를 먼저 알아야 한다:

  1. 전이에 크론이 없다. 회차를 실제로 여닫는 것은 조회 요청뿐이다 — GET /team/:teamId/sessions, GET /team/:teamId/session/current, GET /student/sessions, GET /teacher/attendance가 응답 직전에 전이를 수행한다(의도적 GET write-through). 부수 효과가 지저분해 보인다고 떼면 수업이 영영 열리지 않는다. 반대로 GET /teacher/sessions일부러 전이를 하지 않는다 — 교사가 팀 목록을 여는 것만으로 전 팀의 수업이 열리면 안 되기 때문이다.
  2. 회차 개수는 팀 개설 시점에만 정해진다. 주 N회는 사후에 바꿀 수 없고, 어떤 요청 타입에도 weeklyCount 필드가 없다. 늘리거나 줄이는 것은 단건 추가·삭제 경로의 일이다.
  3. 에러 코드는 공통이다. 서버 레이어의 TeamSessionError를 라우트가 그대로 매핑한다 — NOT_FOUND→404, VALIDATION_ERROR→400, CONFLICT→409.

Firestore 인덱스: teamSessions(teamId, date), (teamId, status), (teamId, status, date ASC), (teamId, status, date DESC)


1. GET /team/:teamId/sessions - 팀 회차 전체 조회

실제 URL: GET /api/team/:teamId/sessions

인증: 필수 (팀 멤버 — 소유자 또는 uid in team.members)

설명: 날짜 오름차순 전체 목록. 이 GET은 쓰기를 한다 — 응답 전에 transitionTeamSessions를 부르므로 담겨 오는 status는 "지금" 기준으로 갱신된 값이다. 전이가 실패해도 조회는 성공한다(저장된 status로 그릴 수 있는 화면은 전부 그려져야 한다). 다만 실패는 반드시 console.error로 남는다 — 크론이 없어 이 로그가 유일한 관측 수단이다.

Response:

{
  success: true,
  data: { sessions: TeamSession[] }
}

에러: 403(팀 멤버 아님), 404(팀 없음)

Manager: teamSessionManager.getTeamSessions(teamId) (캐싱 30초, 조작 후 즉시 무효화)


2. POST /team/:teamId/sessions - 회차 생성 (배치·단건 겸용)

실제 URL: POST /api/team/:teamId/sessions

인증: 필수 (팀 소유자 = 교사만)

Request:

{
  sessions: Array<{
    date: string;                  // "YYYY-MM-DD"
    time: string;                  // "HH:mm"
    weekId: string;
    topicId: string;               // 필수 — 빈 문자열 불가
    writingStages?: WritingStage[]; // 생략 시 서버 기본값
  }>;
}

배열 하나가 배치 생성과 단건 추가를 겸한다. 팀 개설은 주 N회 × 8주개를 한 번에 보내고, 회차 끼워넣기는 길이 1인 배열을 보낸다. 쓰기가 트랜잭션 1회라 부분 성공이 없다 — 24건을 24요청으로 쪼개면 중간 실패 시 절반만 있는 팀이 남는다.

입력 검증은 라우트와 서버 레이어 양쪽에 있다. 라우트가 한 번 더 보는 이유는 24건 중 몇 번째가 잘못됐는지는 라우트만 알려 줄 수 있기 때문이다(sessions[7].topicId가 필요합니다.).

Response (201):

{
  success: true,
  data: { sessions: TeamSession[] }
}

에러: 400(형식·글감 누락), 403(소유자 아님·비활성 팀), 404(팀 없음), 409(같은 날짜 회차가 이미 있음 — 하루 1회차)

Manager: teamSessionManager.createSessions(teamId, inputs) / createSession(teamId, input)


3. PUT /team/:teamId/sessions/:sessionId - 회차 수정

실제 URL: PUT /api/team/:teamId/sessions/:sessionId

인증: 필수 (팀 소유자 = 교사만)

Request (넘긴 필드만 바뀐다. 전부 생략하면 400):

{
  date?: string;
  time?: string;
  topicId?: string;               // 비울 수 없다
  writingStages?: WritingStage[]; // 1개 이상
}

회차가 독립 문서라 인접 회차를 건드리지 않고 한 건만 고칠 수 있다. 구 모델은 일정이 팀 문서 안의 배열이라 3회차 날짜를 옮기려면 배열 전체를 다시 썼다. 회차 ID가 불변이므로 날짜를 옮겨도 그 회차를 가리키던 글·출석은 끊기지 않는다.

Response: { success: true, data: { session: TeamSession } }

에러: 400(형식·수정 항목 없음), 403(소유자 아님·비활성 팀), 404(팀/회차 없음 — 다른 팀의 회차 ID도 404), 409(옮긴 날짜에 이미 회차가 있음)

Manager: teamSessionManager.updateSession(teamId, sessionId, patch)


4. PATCH /team/:teamId/sessions/:sessionId - 수동 열기 / 종료

실제 URL: PATCH /api/team/:teamId/sessions/:sessionId

인증: 필수 (팀 소유자 = 교사만)

Request:

{ action: "open" | "close" }

PUT과 나눈 이유: 열기는 order 부여 + 같은 팀의 다른 진행 회차 종료를 한 트랜잭션에서 하는 상태 전이이지 필드 수정이 아니다. 한 라우트에 섞으면 "날짜만 고치려던 요청이 수업을 여는" 사고가 가능해진다.

  • open: 확정 회차 번호(order)를 부여하고, 같은 팀에 진행 중인 회차가 있으면 함께 닫는다(closedBy: "auto:open"). 팀당 in_progress는 항상 최대 1개다.
  • close: closedAt·closedBy를 남기고 done으로 내린다.

Response: { success: true, data: { session: TeamSession } }

에러: 400(action 누락/오타), 403(소유자 아님·비활성 팀), 404(팀/회차 없음), 409(전이 제약 위반)

Manager: teamSessionManager.openSession(teamId, sessionId) / closeSession(teamId, sessionId)


5. DELETE /team/:teamId/sessions/:sessionId - 회차 삭제

실제 URL: DELETE /api/team/:teamId/sessions/:sessionId

인증: 필수 (팀 소유자 = 교사만)

🔒 scheduled 회차만 지울 수 있다. 진행·완료 회차를 지우면 checkins 서브컬렉션이 부모 없는 고아로 남아 어떤 조회에도 걸리지 않은 채 살아 있다 — 그건 삭제가 아니라 조용한 기록 파기다. 없는 회차는 멱등 성공이다.

Response: { success: true, data: { deleted: true } }

에러: 403(소유자 아님·비활성 팀), 404(팀 없음 · 다른 팀의 회차 ID), 409(scheduled가 아님)

Manager: teamSessionManager.deleteSession(teamId, sessionId)


6. POST /team/:teamId/sessions/regenerate - 일정 규칙 일괄 재생성

실제 URL: POST /api/team/:teamId/sessions/regenerate

인증: 필수 (팀 소유자 = 교사만)

Request:

{ dates: Array<{ date: string; time: string }> }  // 날짜 오름차순

삭제-재생성이 아니라 UPDATE다. 문서 ID가 유지되어야 그 회차를 가리키는 출석·글이 끊기지 않는다. 그래서 개수는 바꿀 수 없고, dates.length가 예정 회차 수와 다르면 400이다.

scheduled 회차의 날짜만 갈아끼운다 — 진행·완료 회차는 날짜도 문서 ID도 바뀌지 않는다. 중복 검사는 진행·완료 회차의 날짜를 포함한 전체 집합에 건다. 예정분만 보고 검사하면 새 날짜가 이미 끝난 회차의 날짜와 겹쳐 하루 2회차가 생긴다.

Response: { success: true, data: { sessions: TeamSession[] } }

에러: 400(형식·같은 날짜 중복·개수 불일치), 403(소유자 아님·비활성 팀), 404(팀 없음), 409(전체 집합 기준 날짜 충돌)

Manager: teamSessionManager.regenerateSessions(teamId, dates)


Teacher API

🆕 1. GET /teacher/stats - 선생님 홈 통계 (미확인/미평가 과제 수)

실제 URL: GET /api/teacher/stats

인증: 필수 (교사 역할, withTeacherAuth)

Response:

{
  success: true,
  data: {
    unevaluatedCount: number;  // 미평가 과제 수
  }
}

미평가 정의: 내 팀(소유한 팀 + 참여한 팀)에 속한 status === "published" 글 중, 내(선생님)가 작성한 댓글이 하나도 없는 글 (comments 컬렉션에서 userId == 나, isDeleted == false 기준으로 판단. 학생 댓글은 평가로 카운트하지 않음)

백엔드 로직:

  1. getAllUserTeams(uid) - 내 팀 목록 조회
  2. comments 컬렉션에서 내가 작성한 댓글의 writingId 집합 조회
  3. writings 컬렉션에서 내 팀들의 published 글 조회 (teamId in [...], 10개 청크 분할)
  4. 댓글 집합에 없는 글 개수 = unevaluatedCount

캐싱: 클라이언트에서 2분간 캐싱

Firestore 인덱스: comments(userId, isDeleted), writings(teamId, status) 복합 인덱스 필요 (firestore.indexes.json 참조)


🆕 2. GET /teacher/attendance - 선생님 홈 출석현황 패널

실제 URL: GET /api/teacher/attendance

인증: 필수 (교사 역할, withTeacherAuth)

설명: 출석의 유일한 진실은 teamSessions/{sessionId}/checkins 서브컬렉션이다. 대상 팀은 오늘 날짜의 회차 문서가 있는 팀이다.

🔴 여기서 불변식이 반전됐다. 구 모델에서 "오늘 날짜의 세션 문서가 있다"는 곧 "수업이 열렸다"였다 — 문서는 수업이 열릴 때 비로소 만들어졌기 때문이다. 신 모델은 개설 시점에 8주치 회차를 미리 만들므로 문서 존재는 아무것도 말해 주지 않고, 열림 여부는 status로만 판정한다. 시간표 역산(isScheduledOn/timeForDate)도 사라졌다 — 회차 문서가 날짜·시각을 직접 든다.

의도적 GET write-through: getTodaySessionsForTeams가 시각이 도래한 회차의 전이를 함께 수행하므로, 아무도 접속하지 않은 수업도 교사가 홈을 열어보는 시점에 정확히 열린다.

Response:

{
  success: true,
  data: {
    teams: Array<{
      teamId: string;
      name: string;
      classTime: string;       // "HH:mm"
      membersTotal: number;     // 팀 소유자 제외 인원수
      attendance: { present: number; late: number } | null; // null = 현재(KST) < classTime, 수업 전 대기
      rows: Array<{ uid: string; name: string; time: string; isLate: boolean }>; // 상위 5명
    }>;
  }
}

백엔드 로직:

  1. getAllUserTeams(uid) - 내 팀 목록 조회
  2. getTodaySessionsForTeams로 오늘 날짜의 회차를 청크 단위 단일 쿼리로 조회 — 여기 걸린 팀만 대상. 오늘 날짜 토큰은 이 함수 안에만 있다(라우트에 날짜 비교를 두지 않는다)
  3. 회차가 scheduled면 아직 시각 전이므로 attendance: null("수업 전 대기")
  4. 아니면 buildAttendanceSummary로 체크인 데이터를 응답 DTO로 집계 (팀원의 최초 도착 시각 = 출석 시각, baselineAt + graceMinutes 초과 시 지각)

캐싱: 클라이언트에서 1분간 캐싱 (teamManager.getTodayAttendance())

Firestore 인덱스: teamSessions(teamId, date) 복합 인덱스 필요 (firestore.indexes.json 참조)


🆕 3. GET /teacher/sessions - 내 팀 전체의 회차 조회 (교사 목록 화면)

실제 URL: GET /api/teacher/sessions

인증: 필수 (교사 역할, withTeacherAuth)

설명: 팀 카드의 요일 칩, 팀 리포트 표의 수업일정·시간·진행 회차, 학습 스케줄 캘린더 — 셋 다 여러 팀의 일정을 한 화면에 그린다. 팀마다 /team/:id/sessions를 부르면 그대로 N+1이라 조회를 여기 하나로 모았다.

🔴 이 GET은 전이를 하지 않는다 — 오프너가 아니다. 여기까지 전이를 걸면 교사가 팀 목록을 여는 것만으로 전 팀의 수업이 열리고, 그 비용은 팀 수만큼의 트랜잭션이 된다. 이 응답은 그리기 위한 스냅샷이다.

Response:

{
  success: true,
  data: { sessions: TeamSession[] }   // 내 모든 팀, 날짜 오름차순
}

Manager: teamSessionManager.getMyTeamSessions() (캐싱 30초)


Student API

🆕 1. GET /student/sessions - 학생 홈 입장 카드용 회차 알림 조회

실제 URL: GET /api/student/sessions

인증: 필수 (withAuth)

설명: 내 모든 팀에 대해 getTeamSessionNotificationsForUser(uid)(알림 파생의 단일 지점)를 호출한다. 각 팀은 회차 전이를 거쳐 lazy 판정(오픈 포함)되므로, 크론이 없는 지금 이 조회가 그 팀의 유일한 관찰자일 수 있다(의도적 GET write-through).

Response:

{
  success: true,
  data: {
    notifications: Array<{
      sessionId: string;
      teamId: string;
      targetUid: string;
      deepLink: string;              // getClassSessionHref(teamId) — 단일 지점에서 생성
      channel: "inapp";               // 현재는 인앱 카드만 소비, 추후 채널 추가 시 이음새
    }>;
  }
}

캐싱: 클라이언트에서 30초 캐싱 (teamSessionManager.getEnterableSessions(), 체크인 후 즉시 무효화). ⚠️ 이 매니저에서 TTL은 성능 손잡이가 아니라 전환 지연 손잡이다 — 크론이 없어 조회가 곧 전이이므로, TTL을 늘리면 "수업이 늦게 열린다"가 같이 늘어난다.


User API

중요: User vs FirestoreUser 구분

  • FirestoreUser: DB 저장용 (uid, createdAt, lastLoginAt, settings만)
  • User: API 응답/UI용 (Firebase Auth + FirestoreUser 결합)

1. POST /user - 사용자 생성

실제 URL: POST /api/user

인증: 필수

Request:

{
  uid: string;           // Firebase Auth UID
  displayName: string;   // Firebase Auth displayName 설정용
  teamId: string;        // 최초 가입 팀
}

Response:

{
  success: true,
  data: {
    user: User;          // Firebase Auth + Firestore 결합된 완전한 User
  }
}

부수 효과:

  • Firestore에 FirestoreUser 생성 (최소 데이터)
  • 팀에 멤버 추가 (team.members[uid])
  • Firebase Auth displayName 설정

캐시 무효화: 팀별 사용자 목록


2. POST /user/purchase - 플랜 구매/변경

실제 URL: POST /api/user/purchase

인증: 필수

Request:

{
  planType: PlanType;              // FREE, PRO, CLASSROOM, ACADEMY, SCHOOL
  billingCycle: BillingCycle;      // MONTHLY, YEARLY
  amount: number;                  // 결제 금액 (원)
  downgradeMode?: "immediate" | "scheduled";  // 다운그레이드 시 적용 시점
}

Response:

{
  success: true,
  data: {
    creditsAdded: number;   // 환불로 지급된 크레딧 (업그레이드/즉시 다운그레이드)
    isScheduled: boolean;   // true면 다음 결제일에 적용
    isBillingCycleChange: boolean;  // true면 결제 주기만 변경
  }
}

동작:

  1. 업그레이드: 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
  2. 다운그레이드 (immediate): 즉시 적용, 남은 기간 비례 환불 → 크레딧 지급
  3. 다운그레이드 (scheduled): scheduledPlan에 저장, 만료 시 자동 적용
  4. 결제 주기 변경: scheduledPlan에 저장, 만료 시 새 주기로 적용

환불 계산:

const refundKRW = calculateProratedRefund(currentPlan, PLAN_MONTHLY_PRICES);
const credits = convertKRWToCredits(refundKRW);  // 100원 = 10 크레딧

캐시 무효화: 사용자 정보


3. GET /user/plan/estimate-refund - 예상 환불 크레딧 조회

실제 URL: GET /api/user/plan/estimate-refund

인증: 필수

설명: 다운그레이드 전 예상 환불 크레딧을 조회합니다. UI에서 "즉시 다운그레이드" 옵션에 표시됩니다.

Response:

{
  success: true,
  data: {
    estimatedCredits: number;      // 예상 환불 크레딧
    estimatedKRW: number;          // 예상 환불 금액 (원)
    validUntil: string;            // 현재 플랜 만료일 (ISO 8601)
    currentPlanType: PlanType;     // 현재 플랜 타입
  }
}

에러:

  • 400: 활성 플랜 없음
  • 401: 인증 필요

캐싱: 없음 (실시간 계산)


Writing API

1. POST /writing - 글 생성

실제 URL: POST /api/writing

인증: 필수

Request:

{
  title: string;
  content: string;
  status?: "draft" | "published";
  topicId?: string | null;
}

Response:

{
  success: true,
  data: {
    writingId: string;
    writing: Writing;
  }
}

부수 효과: 서버에서 wordCount, charCount 자동 계산

캐시 무효화: 사용자 글 목록, 최근 글


2. GET /writing/:id - 글 조회

실제 URL: GET /api/writing/:id

인증: 필수

권한: 작성자만 조회 가능 (writing.userId === currentUserId)

Response:

{
  success: true,
  data: {
    writing: Writing | null;
  }
}

에러:

  • 404 Not Found: 글이 존재하지 않음
  • 403 Forbidden: 작성자가 아님

캐싱: 클라이언트에서 5분간 캐싱


3. POST /writing/user - 사용자의 글 목록

실제 URL: POST /api/writing/user

인증: 필수

Request:

{
  userId?: string;  // 없으면 현재 사용자
}

Response:

{
  success: true,
  data: {
    writings: Writing[];
  }
}

캐싱: 클라이언트에서 1분간 캐싱


4. POST /writing/recent - 최근 글

실제 URL: POST /api/writing/recent

인증: 필수

Request:

{
  limit?: number;  // 기본값: 5
}

Response:

{
  success: true,
  data: {
    writings: Writing[];
  }
}

캐싱: 클라이언트에서 30초간 캐싱


5. PUT /writing/:id - 글 수정

실제 URL: PUT /api/writing/:id

인증: 필수

Request:

{
  writingId: string;
  data: {
    title?: string;
    content?: string;
    status?: "draft" | "published";
    topicId?: string | null;
    wordCount?: number;
    charCount?: number;
    distortionAreas?: DistortionAreaData[]; // 🆕 왜곡 영역 설정
    analysis?: WritingAnalysis; // 🆕 AI 분석 결과 (영역 제한용)
  }
}

Response:

{
  success: true,
  data: {
    writing: Writing;
  }
}

권한: 작성자만 수정 가능

캐시 무효화: 해당 글, 사용자 글 목록, 최근 글


6. DELETE /writing/:id - 글 삭제

실제 URL: DELETE /api/writing/:id

인증: 필수

Request:

{
  writingId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 작성자만 삭제 가능

캐시 무효화: 해당 글, 사용자 글 목록, 최근 글


7. POST /writing/:id/analyze - 글 분석 실행

실제 URL: POST /api/writing/:id/analyze

인증: 필수 (작성자 본인만 가능)

설명: 저장된 글을 서버에서 불러와 AI 분석을 실행하고 결과를 저장합니다.

Request:

{
  locale?: "ko" | "en" | "ja";  // 기본값: "ko"
}

Response:

{
  success: true,
  data: {
    analysis: WritingAnalysis;
    cached: boolean;  // true면 기존 분석 결과 반환 (재사용)
  }
}

특징:

  • Content Hash 기반 재사용: 글 내용이 변경되지 않았으면 기존 분석 결과 반환 (비용 절감)
  • 최소 길이: 30자 이상이어야 분석 가능
  • 저장: 분석 결과는 자동으로 writings/{id}.analysis에 저장됨

Comment API

1. GET /comment/writing/:writingId - 댓글 목록 조회

실제 URL: GET /api/comment/writing/:writingId

인증: 선택적 (현재 사용자 반응 확인용)

Response:

{
  success: true,
  data: {
    comments: CommentWithReplies[];
    totalCount: number;
  }
}

특징:

  • 계층 구조 (댓글 + 답글) 반환
  • 작성자 정보 (displayName, photoURL) 포함
  • 현재 사용자의 반응 포함 (로그인 시)

2. POST /comment/writing/:writingId - 댓글 작성

실제 URL: POST /api/comment/writing/:writingId

인증: 필수

Request:

{
  content: string;
  parentId?: string; // 답글인 경우 부모 댓글 ID
}

Response:

{
  success: true,
  data: {
    comment: Comment;
  }
}

3. PUT /comment/:id - 댓글 수정

실제 URL: PUT /api/comment/:id

인증: 필수 (작성자 본인만)

Request:

{
  content: string;
}

Response:

{
  success: true,
  data: {
    comment: Comment;
  }
}

4. DELETE /comment/:id - 댓글 삭제

실제 URL: DELETE /api/comment/:id

인증: 필수

권한:

  • 작성자 본인
  • 글 작성자 (관리 차원)
  • 팀 소유자 (팀 주제인 경우)

Response:

{
  success: true
}

Notice API

1. GET /notice - 공지사항 목록 조회

실제 URL: GET /api/notice

인증: 필수 (일반 사용자)

Response:

{
  success: true,
  data: {
    notices: Notice[]; // 고정 공지 우선, 이후 최신순
  }
}

2. POST /notice - 공지사항 생성

실제 URL: POST /api/notice

인증: 필수 (관리자 전용, Custom Claim isAdmin === true)

Request:

{
  title: string;
  body: string;
  pinned?: boolean;
}

Response:

{
  success: true,
  data: {
    notice: Notice;
  }
}

3. PUT /notice/:noticeId - 공지사항 수정

실제 URL: PUT /api/notice/:noticeId

인증: 필수 (관리자 전용)

Request:

{
  title?: string;
  body?: string;
  pinned?: boolean;
}

Response:

{
  success: true,
  data: {
    notice: Notice;
  }
}

4. DELETE /notice/:noticeId - 공지사항 삭제

실제 URL: DELETE /api/notice/:noticeId

인증: 필수 (관리자 전용)

Response:

{
  success: true
}

Topic API

1. POST /topic/available - 사용 가능한 주제 목록

실제 URL: POST /api/topic/available

인증: 필수

Request:

{
  teamIds?: string[];    // 팀 주제를 가져올 팀 ID 목록
}

Response:

{
  success: true,
  data: {
    topics: Topic[];
  }
}

백엔드 로직:

  1. 개인 주제: ownerType === PERSONAL && ownerId === currentUserId
  2. 팀 주제: ownerType === TEAM && ownerId in [teamId1, teamId2, ...]
    • 클라이언트에서 teamIds: ["team1", "team2"] 전달
    • 서버에서 필터링
  3. 모든 주제를 병합하여 반환

캐싱: 클라이언트에서 5분간 캐싱

참고: 그룹(Group) 기능은 제거되었습니다. 팀(Team) 기능만 사용합니다.

참고: 이 엔드포인트는 PERSONAL + TEAM 글감만 반환합니다. 시스템 글감(SYSTEM)은 포함되지 않습니다.


1-B. GET /topic?weekId= - 주차별 시스템 글감 목록

실제 URL: GET /api/topic?weekId={weekId}

인증: 불필요 (시스템 콘텐츠)

Query Parameters:

  • weekId: string (필수 — 예: "g3-c1-w01")

Response:

{
  success: true,
  data: {
    topics: Topic[];   // ownerType === SYSTEM, topic.weekId === weekId
  }
}

Manager 메서드:

  • topicManager.getByWeek(weekId)

캐싱: 클라이언트에서 5분간 캐싱

참고: 시스템 글감(128개/학년)은 이 엔드포인트를 통해서만 접근 가능합니다. weekId 없이 호출하면 400 에러를 반환합니다.


2. GET /topic/:id - 주제 조회

실제 URL: GET /api/topic/:id

인증: 선택적

Response:

{
  success: true,
  data: {
    topic: Topic | null;
  }
}

캐싱: 클라이언트에서 5분간 캐싱


3. POST /topic - 개인 주제 생성

실제 URL: POST /api/topic

인증: 필수

Request:

{
  title: string;
  description: string;
  category: "daily" | "imagination" | "emotion" | "experience";
  difficulty: "easy" | "medium" | "hard";
  keywords?: string[];
  examplePrompts?: string[];
  titleTemplate?: string;
  contentTemplate?: string;
}

Response:

{
  success: true,
  data: {
    topicId: string;
    topic: Topic;
  }
}

권한: 로그인한 사용자가 자동으로 소유자가 됨

캐시 무효화: 주제 목록


4. PUT /topic/:id - 개인 주제 수정

실제 URL: PUT /api/topic/:id

인증: 필수

Request:

{
  topicId: string;
  data: {
    title?: string;
    description?: string;
    category?: "daily" | "imagination" | "emotion" | "experience";
    difficulty?: "easy" | "medium" | "hard";
    keywords?: string[];
    examplePrompts?: string[];
    titleTemplate?: string;
    contentTemplate?: string;
    isActive?: boolean;
  }
}

Response:

{
  success: true,
  data: {
    topic: Topic;
  }
}

권한: 주제 소유자만 수정 가능

캐시 무효화: 해당 주제, 주제 목록


5. DELETE /topic/:id - 개인 주제 삭제

실제 URL: DELETE /api/topic/:id

인증: 필수

Request:

{
  topicId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한: 주제 소유자만 삭제 가능

캐시 무효화: 해당 주제, 주제 목록


6. POST /topic/increment-usage - 주제 사용 횟수 증가

실제 URL: POST /api/topic/increment-usage

인증: 선택적

Request:

{
  topicId: string;
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

참고: 실패해도 클라이언트는 에러를 무시 (크리티컬하지 않음)


7. POST /topic/team - 팀 주제 생성

실제 URL: POST /api/topic/team

인증: 필수 (팀 소유자만)

Request:

{
  teamId: string;      // 팀 ID (ownerId = teamId)
  title: string;
  description: string;
  category: "daily" | "imagination" | "emotion" | "experience";
  difficulty: "easy" | "medium" | "hard";
  keywords?: string[];
  examplePrompts?: string[];
  titleTemplate?: string;
  contentTemplate?: string;
}

Response:

{
  success: true,
  data: {
    topicId: string;
    topic: Topic;       // ownerType: TEAM, ownerId: teamId
  }
}

권한:

  • 로그인한 사용자가 팀 소유자인지 확인 (team.ownerId === currentUserId)
  • 팀이 활성화 상태인지 확인 (team.isActive === true)

백엔드 처리:


const topic = {
  ...requestData,
  ownerType: TopicOwnerType.TEAM,
  ownerId: teamId, 
  createdBy: currentUserId,
  usageCount: 0,
  isActive: true,
  createdAt: serverTimestamp(),
  updatedAt: serverTimestamp(),
};

캐시 무효화: 주제 목록, 팀 주제 목록


8. GET /topic/team/:teamId - 팀 주제 목록 조회

실제 URL: GET /api/topic/team/:teamId

인증: 선택적 (공개 조회 가능)

Response:

{
  success: true,
  data: {
    topics: Topic[];    // ownerType === TEAM && ownerId === teamId
  }
}

백엔드 로직:


const topics = await db.collection('topics')
	.where('ownerType', '==', TopicOwnerType.TEAM)
	.where('ownerId', '==', teamId)
	.where('isActive', '==', true)
	.orderBy('createdAt', 'desc')
	.get();

캐싱: 클라이언트에서 5분간 캐싱


9. PUT /topic/team/:id - 팀 주제 수정

실제 URL: PUT /api/topic/team/:id

인증: 필수 (팀 소유자만)

Request:

{
  topicId: string;
  teamId: string;      // 소유권 검증용
  data: {
    title?: string;
    description?: string;
    category?: "daily" | "imagination" | "emotion" | "experience";
    difficulty?: "easy" | "medium" | "hard";
    keywords?: string[];
    examplePrompts?: string[];
    titleTemplate?: string;
    contentTemplate?: string;
    isActive?: boolean;
  }
}

Response:

{
  success: true,
  data: {
    topic: Topic;
  }
}

권한 검증:

  1. 주제가 팀 주제인지 확인: topic.ownerType === TEAM && isTeamOwnerId(topic.ownerId)
  2. 요청한 teamId와 주제의 teamId 일치 확인: extractTeamId(topic.ownerId) === teamId
  3. 현재 사용자가 팀 소유자인지 확인: team.ownerId === currentUserId

캐시 무효화: 해당 주제, 주제 목록, 팀 주제 목록


10. DELETE /topic/team/:id - 팀 주제 삭제

실제 URL: DELETE /api/topic/team/:id

인증: 필수 (팀 소유자만)

Request:

{
  topicId: string;
  teamId: string;      // 소유권 검증용
}

Response:

{
  success: true,
  data: {
    success: true;
  }
}

권한 검증: PUT과 동일

캐시 무효화: 해당 주제, 주제 목록, 팀 주제 목록

참고: Soft delete 방식 (isActive: false로 설정)


에러 코드

코드 설명
UNAUTHORIZED 인증 필요
FORBIDDEN 권한 없음
NOT_FOUND 리소스 없음
VALIDATION_ERROR 유효성 검사 실패
TEAM_INACTIVE 비활성화된 팀
PIN_REQUIRED PIN 입력 필요
PIN_INVALID PIN 불일치
STUDENT_NAME_DUPLICATE 팀 내 이름 중복
ALREADY_EXISTS 리소스 중복
INTERNAL_ERROR 서버 오류

구현 노트

Next.js API Routes 구현 예시

// app/api/team/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyIdToken } from '@/lib/auth';
import { createTeam } from '@/services/teamService';

export async function POST(request: NextRequest) {
  try {
    // 1. 인증 확인
    const token = request.headers.get('Authorization')?.replace('Bearer ', '');
    const decoded = await verifyIdToken(token);

    if (!decoded) {
      return NextResponse.json({
        success: false,
        error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' }
      }, { status: 401 });
    }

    // 2. Request 파싱
    const body = await request.json();

    // 3. 유효성 검사
    if (!body.name || !body.code) {
      return NextResponse.json({
        success: false,
        error: { code: 'VALIDATION_ERROR', message: '필수 필드가 누락되었습니다.' }
      }, { status: 400 });
    }

    // 4. 비즈니스 로직 실행
    const teamId = await createTeam({
      ...body,
      ownerId: decoded.uid
    });

    // 5. 응답
    return NextResponse.json({
      success: true,
      data: { teamId, team: {...} }
    });

  } catch (error: any) {
    return NextResponse.json({
      success: false,
      error: { code: 'INTERNAL_ERROR', message: error.message }
    }, { status: 500 });
  }
}

Server Actions 구현 예시

// app/actions/team.ts
'use server'

import { auth } from '@/lib/auth';
import { createTeam as createTeamFirestore } from '@/services/teamService';
import type { ApiResponse } from '@/types/api';
import type { CreateTeamRequest, CreateTeamResponse } from '@/types/api/team';

export async function createTeam(data: CreateTeamRequest): Promise<ApiResponse<CreateTeamResponse>> {
  try {
    const session = await auth();

    if (!session) {
      return {
        success: false,
        error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' }
      };
    }

    const teamId = await createTeamFirestore({
      ...data,
      ownerId: session.uid
    });

    return {
      success: true,
      data: { teamId, team: {...} }
    };
  } catch (error: any) {
    return {
      success: false,
      error: { code: 'INTERNAL_ERROR', message: error.message }
    };
  }
}

보안 고려사항

  1. 인증 토큰 검증: 모든 쓰기 작업은 Firebase ID Token 검증 필수
  2. 권한 체크: 팀 소유자 확인 (ownerId === decoded.uid)
  3. 입력 검증: 모든 입력값 sanitization 및 validation
  4. Rate Limiting: Redis로 API 호출 횟수 제한 (선택적)
  5. PIN 보안: PIN은 평문으로 받아 서버에서 SHA-256 해시로 저장

Redis 캐싱 전략 (서버 사이드)

캐싱 대상

  • 팀 정보: redis:team:{teamId} - TTL 5분
  • 학생 정보: redis:student:{studentId} - TTL 5분
  • 팀별 학생 목록: redis:students:team:{teamId} - TTL 30초

캐시 무효화

  • 팀 생성/수정/삭제 시: 해당 팀 + 팀 목록
  • 학생 생성/수정 시: 해당 학생 + 팀별 학생 목록
  • 강퇴 시: 학생 + 팀 + 팀별 학생 목록

클라이언트 캐싱 전략

매니저 레벨에서 in-memory 캐싱 (SingletonManager):

  • 조회 작업: 캐싱 활성화 (GET 요청)
  • 변경 작업: 캐싱 안 함 (POST/PUT/DELETE)
  • 캐시 무효화: 변경 작업 시 관련 캐시 자동 무효화

개발 순서

  1. API 타입 정의 (src/types/api.ts, src/types/api/team.ts, src/types/api/student.ts)
  2. BaseManager에 authenticatedFetch, callApi 구현
  3. BaseManager에 클라이언트 캐싱 메서드 구현
  4. TeamManager, StudentManager를 API 호출 방식으로 전환
  5. Next.js API Routes 또는 Server Actions 구현
  6. Redis 캐싱 구현 (선택적)
  7. Rate Limiting 구현 (선택적)

Curriculum & Week API

데이터 계층: Curriculum(4개/학년) → CurriculumWeek(8개/커리큘럼, 32개/학년) → Topic(4개/주차, 128개/학년)

  • Curriculum 컬렉션: id(예: "g3-c1"), grade, track("C1"~"C4"), name, description, totalWeeks, isActive
  • CurriculumWeek 컬렉션: id(예: "g3-c1-w01"), curriculumId, grade, week(1~8), name, weekGoal, competency, learningGoal, writingActivity, keyExpressions, output, teacherPoint, topicCriteria, drawingDirection, isActive
  • 시스템 데이터는 읽기 전용(인증 불필요). 관리자 쓰기 엔드포인트는 미구현.

GET /api/curriculum

커리큘럼 목록 조회 (학년별 필터 선택적)

Query Parameters:

  • grade: number (선택 — 학년 필터)

Response:

{
  success: true,
  data: { curricula: Curriculum[] }
}

Manager 메서드:

  • curriculumManager.getByGrade(grade?) — grade 없으면 전체 반환

인증: 불필요 (시스템 콘텐츠)

캐싱: 클라이언트 캐싱 (TTL 5분)


GET /api/curriculum/[curriculumId]

커리큘럼 단건 조회

Response:

{
  success: true,
  data: { curriculum: Curriculum }
}

Manager 메서드:

  • curriculumManager.getCurriculum(curriculumId)

인증: 불필요 (시스템 콘텐츠)

캐싱: 클라이언트 캐싱 (TTL 5분)


GET /api/curriculum/[curriculumId]/week

커리큘럼의 주차 목록 조회 (8개)

Response:

{
  success: true,
  data: { weeks: CurriculumWeek[] }
}

서버 로직:

  • 커리큘럼이 존재하지 않으면 404
  • curriculumId로 필터링, week 오름차순 정렬

Manager 메서드:

  • curriculumManager.getWeeks(curriculumId)

인증: 불필요 (시스템 콘텐츠)

캐싱: 클라이언트 캐싱 (TTL 5분)


GET /api/curriculum/[curriculumId]/week/[weekId]

주차 단건 조회

Response:

{
  success: true,
  data: { week: CurriculumWeek }
}

서버 로직:

  • week.curriculumId !== curriculumId면 404 (크로스 커리큘럼 가드)

Manager 메서드:

  • curriculumManager.getWeek(curriculumId, weekId)

인증: 불필요 (시스템 콘텐츠)

캐싱: 클라이언트 캐싱 (TTL 5분)