데이터 모델
Firestore 기반 멀티테넌트 데이터 모델. 경로 기반 격리 (Path-based isolation).
모든 데이터는 tenants/{tenantId} 하위에 격리됩니다. 테넌트 간 데이터 교차 접근은 불가능합니다.
엔터티 관계도
전체 구조
Firestore
├── config/
│ └── appMetadata ← 글로벌 설정 (플랜 한도, 기능 플래그, 점검 모드)
│
├── promoCodes/{code} ← 프로모션 코드 (루트 레벨)
│
└── tenants/{tenantId}/ ← 테넌트 (사무소) 단위 격리
├── members/{userId} ← 구성원 (owner, staff)
├── activityLogs/{logId} ← 활동 감사 로그
├── notifications/{notifId} ← 인앱 알림
├── documents/{docId} ← AI 생성/업로드 서류
├── sentDocuments/{sentId} ← 발송된 서류
├── messages/{msgId} ← 의뢰인-사무소 메시지 (레거시, case-level로 이동 중)
├── fees/{feeId} ← 수임료 청구서
├── payments/{orderId} ← 결제 내역
├── portalTokens/{token} ← 포털 인증 토큰 (portalInvites 대체)
├── portalInvites/{token} ← 포털 초대 토큰 (레거시, 삭제 예정)
├── legacyDocuments/{docId} ← 종결 사건 OCR 아카이브 (Pro)
├── counters/ai-assists ← AI 어시스트 통합 월간 카운터 (문서 생성·사무실 기억 분석·전략 보고서 등 weight 0/1/3 차감)
├── dailyBriefings/{YYYY-MM-DD} ← 대시보드 브리핑 공유 캐시 (하루 1회 Gemini, #83)
├── docgenEvents/{eventId} ← 서류 생성 텔레메트리 (ADR 0012 S1, 성공·실패·kill 3 경로)
├── relatedMemoriesEvents/{eventId} ← RelatedMemoriesPanel 텔레메트리 (ADR 0012 S1 B, 3 이벤트 타입)
├── docxTemplates/{kind}.docx ← tenant custom 서식 템플릿 (Storage, owner 업로드)
├── meta/caseCounters ← 사건번호 월별 채번 카운터
│
└── cases/{caseId}/ ← 사건
├── hearings/{id} ← 기일
├── recoveries/{id} ← 변제 내역
├── evidence/{id} ← 증거자료
├── comments/{id} ← 내부 노트 (의뢰인 비공개)
├── strategyReports/{id} ← AI 전략 보고서
├── messages/{id} ← 의뢰인↔변호사 사건별 메시지
└── logs/{id} ← 사건 로그
Cloud Storage 경로
tenants/{tenantId}/
├── evidence/{caseId}/{fileName} ← 증거파일
├── documents/{docId}.html ← 서류 HTML
├── documents/{fileName} ← 업로드 서류
├── legacy-ocr/{legacyDocId}/original.txt ← OCR 원본 (PII 포함)
├── generatedDocs/{caseId}/{kind}-{uuid}.docx ← AI 생성 .docx (ADR 0012 S1, Signed URL 15분)
├── docxTemplates/{kind}.docx ← tenant custom 서식 템플릿 (owner 업로드)
└── public/docxTemplates/{kind}.docx ← 공용 기본 서식 (운영팀 관리)
핵심 엔티티
Tenant (사무소)
tenants/{tenantId}
├── name: string — 사무소명
├── ownerUid: string — 생성자 UID
├── ownerName?: string — 대표 이름
├── phone?: string — 연락처
├── email?: string — 이메일
├── address?: string — 주소
├── licenseType?: string — "법무사" | "변호사"
├── licenseNumber?: string
├── logoUrl?: string — 로고 이미지 URL
├── plan: UserPlan — "registered" 단일 tier (ADR 0002 전 사용자 무료, tier 차등 없음)
├── planOverrides?: { assistLimit?: number } — 운영팀 재량 한도 override (어뷰징 방어 한정)
├── aiBanned?: boolean — AI 호출 즉시 차단 플래그 (어뷰징 방어, audit log 필수)
├── stats?: — KPI 사전 집계 (aggregateCaseStats Cloud Function이 자동 갱신)
│ ├── totalCases: number
│ ├── activeCases: number
│ ├── totalAmount: number
│ ├── recoveredAmount: number
│ ├── statusDistribution: { 소제기, 진행중, 판결대기, 집행중, 종결: number }
│ └── updatedAt: Timestamp
├── createdAt: Timestamp
└── updatedAt?: Timestamp
ADR 0002 (전 사용자 무료) 후 제거된 유료 tier 필드:
planExpiresAt·planSource·promoCode·promoExpiresAt·subscription·subscriptionRequest는 타입(apps/web/types/tenant.ts)에서 삭제됨. 레거시 Firestore 문서에 잔존할 수 있으나 런타임은 참조하지 않는다 (Phase 1 후속 마이그레이션에서 필드 자체 제거 예정). 신규 기획·코드에서 "유료 전환" · "Pro tier" 개념 사용 금지.
Case (사건)
tenants/{tenantId}/cases/{caseId}
├── caseNumber: string — 내부 사건번호 (YYMM-NNN)
├── courtCaseNumber?: string — 법원 사건번호
├── status: string — "소제기" | "진행중" | "판결대기" | "집행중" | "종결"
├── recoveryType: string — 14종 중 1 (아래 Pack 매핑 표 참조). 기본 "debt"
├── clientName: string — 의뢰인 이름
├── clientPhone?: string — 의뢰인 전화번호
├── clientEmail?: string — 의뢰인 이메일
├── opponentName: string — 상대방
├── principal: number — 원금
├── totalAmount: number — 청구 총액 (NON_DEBT 8종은 0 — calcLoanAmounts 우회)
├── interestRate?: number — 이자율
├── assignee?: string — 담당자
├── court?: string — 관할 법원
├── createdAt: Timestamp
└── updatedAt?: Timestamp
RecoveryType — Pack 1~5 14종 매핑
recoveryType 은 사건의 도메인을 결정하고, 금액 계산 분기 + wizard UI 분기 + AI 프롬프트 컨텍스트 의 SSoT.
| Pack | recoveryType | 한글명 | 채권 계산 | ADR |
|---|---|---|---|---|
| Pack 1 — 채권 회수 | debt | 대여금 | ✅ calcLoanAmounts | 0003·0010 |
construction | 공사대금 | ✅ | 0010 | |
subrogation | 구상금 | ✅ | 0010 | |
agreement | 약정금 | ✅ | 0010 | |
lease-deposit | 임대차보증금 반환 | ✅ | 0010 | |
assigned | 양수금 | ✅ | 0010 | |
| Pack 2 — 가사 | divorce | 이혼 | ❌ NON_DEBT | 0022 |
| Pack 3 — 부동산 | real-estate-eviction | 명도청구 | ❌ NON_DEBT | 0023 |
rent-arrears | 차임 청구 | ❌ NON_DEBT | 0023 | |
title-transfer | 이전등기청구 | ❌ NON_DEBT | 0023 | |
| Pack 4 — 상속 | inheritance-share | 상속분 청구 | ❌ NON_DEBT | 0026 |
inheritance-division | 유산분할 | ❌ NON_DEBT | 0026 | |
| Pack 5 — 계약 | unjust-enrichment | 부당이득 | ❌ NON_DEBT | 0027 |
contract-damages | 계약 위반 손해배상 | ❌ NON_DEBT | 0027 |
- 채권 계산 (✅) =
packages/business-logic/src/cases/loan-amounts.ts의calcLoanAmounts(이자·지연손해금) 적용. Pack 1 6종만. - NON_DEBT (❌) = 8종.
packages/business-logic/src/cases/create.ts의NON_DEBT_RECOVERY_TYPESSet 으로 분기.totalAmount=0,interestAmount=0,delayDamage=0으로 저장하고 도메인별 별도 필드 (혼인기간·차임·상속분·계약위반사유 등) 사용. - 타입 정의 SSoT =
apps/web/app/(workspace)/cases/[caseId]/_lib/recovery-input-schemas.ts의RECOVERY_TYPES배열 + 파생RecoveryTypeunion. 추가·변경 시 본 표 +NON_DEBT_RECOVERY_TYPESSet + 해당 Pack 의 ADR 동시 갱신.
Member (구성원)
tenants/{tenantId}/members/{userId}
├── tenantId: string
├── name: string
├── email: string
├── role: string — "owner" | "staff"
├── status: string — "active" | "pending" | "inactive"
├── joinedAt: Timestamp
└── lastActiveAt?: Timestamp
Document (서류)
tenants/{tenantId}/documents/{docId}
├── name: string — 서류명
├── type: string — "지급명령" | "소장" | "내용증명" | "강제집행신청서" | ...
├── caseId: string — 연결된 사건 ID
├── content?: string — HTML 본문
├── storageUrl?: string — Storage 파일 URL
├── storagePath?: string — Storage 경로
├── status: string — "생성중" | "완료" | "발송됨"
├── aiGenerated: boolean — AI 생성 여부
├── createdAt: Timestamp
└── updatedAt?: Timestamp
접근 제어
Firestore Security Rules
| 컬렉션 | owner | staff | 비고 |
|---|---|---|---|
| tenants | 읽기/수정 | 읽기 | 삭제 불가 (Admin SDK) |
| cases | 읽기/쓰기 | 읽기/쓰기 | |
| hearings, recoveries, evidence, logs | 읽기/쓰기 | 읽기/쓰기 | 사건 하위 |
| comments | 읽기/쓰기 | 읽기/쓰기 | 내부 노트 (의뢰인 비공개) |
| documents | 읽기/쓰기 | 읽기/쓰기 | 테넌트 레벨 |
| members | CRUD | 읽기 | owner만 멤버 추가/수정/삭제 |
| notifications | 읽기 + readBy 업데이트 | 읽기 + readBy 업데이트 | 생성/삭제는 Admin SDK |
| fees, payments | 읽기/쓰기 | 읽기/쓰기 | 의뢰인 비공개 |
| legacyDocuments | 읽기 | 읽기 | 쓰기는 Cloud Functions |
| portalTokens | 읽기 | 읽기 | 생성/수정은 Admin SDK |
| portalInvites | 읽기 | 읽기 | 레거시, 삭제 예정 |
| promoCodes | 불가 | 불가 | Admin SDK 전용 |
| config/appMetadata | 읽기 | 읽기 | 쓰기는 Admin SDK (콘솔) |
Server Action 권한 체크
getServerSession()— 토큰 서명 검증 + Custom Claims 확인requireStaffSession()— owner/staff만 허용session.role === "owner"— 설정, 멤버 관리, 플랜 변경
한도 (단일 tier)
config/appMetadata.planLimits.registered 에서 관리. 서버에서 getEffectivePlan(plan, _unused, metadata) 또는 getEffectivePlanForSession(session) 로 조회.
ADR 0002 (전 사용자 무료, 2026-04-19) 이후 플랜 tier 차별 · 트라이얼 · grace 개념 소멸. 모든 tenant 는 동일한 운영 안정성 한도 (어뷰징 방어) 만 공유한다.
| 항목 | 한도 |
|---|---|
| 활성 사건 | 200건 |
| AI 월간 호출 | 60회 (단일 카운터, weight 1·3·0 합산) |
| 구성원 | 15명 |
| 레거시 문서 DB | 100건 |
- 한도 초과 시
PLAN_LIMIT_EXCEEDED에러 반환 (트라이얼·grace 차단은 제거) - 한도 값은 Firestore 에서만 변경 가능 — 코드
DEFAULT_METADATA.planLimits.registered는 fallback 용 - ADR 0002 (2026-04-19) 이후 3-tier·트라이얼·
PLAN_READ_ONLY차단 설계는 삭제. 과거 설계는 git history 참조.
AI 기능 플래그
config/appMetadata 문서의 features에서 관리.
| 플래그 | 기본값 | 설명 |
|---|---|---|
features.ai.enabled | true | AI 전체 활성화 |
features.ai.docGeneration | true | AI 서류 생성 |
features.ai.briefing | true | 대시보드 브리핑 |
features.ai.ragSearch | true | RAG 사무실 기억 검색 |
features.ai.strategyReport | false | 전략 보고서 (단계적 배포) |
계획된 변경사항
의뢰인 포털 재설계 (구현 완료)
- ✅
portalInvites→portalTokens로 대체 (사건 단위 토큰) - ✅
messages→cases/{caseId}/messages로 이동 (사건별 대화) - ✅
clientId필드 제거 (Firebase Auth UID 불필요) - ✅
UserRole에서"client"제거 → iron-session 포털 세션으로 전환 - ✅ 4자리 접속 코드 단일 경로 본인확인 (변호사가 링크·코드를 직접 전달)
- ✅ 접속 코드 오입력 누적 시 토큰 자동 revoke + 3경로 대응 (인앱 알림,
/activity감사 로그, 사건 상세PortalLockoutBanner) - ✅
NotificationType/ActivityType에portal_access_code_locked신설 (actorId: "system"관례) - 상세: 포털 재설계 문서