본문으로 건너뛰기

의뢰인 포털 재설계 기획서

구현 상태: ✅ Phase 1~3 구현 완료 (2026-04-09) 변경사항: OTP/OAuth 경로를 모두 걷어내고 4자리 접속 코드 단일 경로로 단순화. SOLAPI 자동 발송 → 변호사가 직접 링크·코드 전달 (복사+카톡 공유).

구현 완료

Phase 1(인증 전환), Phase 2(메시징), Phase 3(레거시 정리)가 모두 구현되었습니다. 포털 본인확인은 4자리 접속 코드 단일 경로이며, 외부 OAuth 공급자는 사용하지 않습니다.

전체 플로우

1. 핵심 원칙

의뢰인 ≠ 서비스 유저
의뢰인 = 하나의 사건에 종속된 외부 열람자
  • 의뢰인은 회원가입하지 않는다
  • 의뢰인은 Firebase Auth 계정을 갖지 않는다
  • 의뢰인은 사건 단위로 링크를 받아 접근한다
  • 의뢰인 접근은 플랜(free/starter/pro)과 무관하다
  • 한 사건에 여러 의뢰인이 존재할 수 있다 (공동 원고 등)

2. 접근 플로우

[법무사/직원 — admin] [의뢰인 — 외부]

사건 등록 시 의뢰인 정보 입력
(이름, 이메일, 전화번호)

├─ 사건 상세 → "포털 공유" 클릭

├─ Server Action: createPortalTokenAction
│ ├─ portalTokens/{token} 문서 생성 (4자리 접속 코드 동시 생성)
│ ├─ 기존 토큰이 있으면 자동 revoke
│ └─ 변호사가 URL + 4자리 코드를 함께 직접 전달 (카카오톡/문자 수동 공유)
│ ※ 만료 없음. 변호사의 수동 revoke 또는 오입력 누적 시 자동 revoke

│ /portal/view?token=abc123
│ 링크 클릭
│ │
│ 1. 토큰 검증
│ ├─ active → 인증 화면
│ └─ revoked → 안내 화면 (재발급 요청)
│ │
│ 2. 접속 코드 입력 (4자리 숫자)
│ ├─ 평문 비교, 자동 포커스 이동
│ └─ 오입력 10회 누적 시 토큰 자동 revoke
│ │
│ 3. 코드 일치 → 세션 쿠키 발급
│ ├─ 30일 sliding window
│ └─ 접속 시마다 자동 갱신
│ │
│ 4. 포털 화면
│ ├─ 사건 정보 (읽기)
│ ├─ 기일 일정 + D-day
│ ├─ 진행 경과 타임라인
│ ├─ 서류 확인/다운로드
│ └─ 대화 (질문/문의)

├─ admin 사건 상세 → 대화 탭 (NEW)
│ └─ 의뢰인과의 대화 확인 + 답변

└─ 사건 액티비티 로그에 의뢰인 접속 기록

2.1 접속 코드 자동 잠금 대응

의뢰인이 접속 코드를 누적 ACCESS_CODE_MAX_ATTEMPTS (기본 10회) 틀리면 verifyAccessCodeAction 이 해당 포털 토큰을 자동으로 status: "revoked" 로 전환한다. 단순히 차단만 하면 변호사가 의뢰인 연락을 받을 때까지 상황을 인지하지 못하므로, 세 가지 경로로 동시에 사무소에 노출한다.

경로저장 위치소비 UI용도
인앱 알림 (writeNotification)tenants/{tid}/notifications대시보드 종 아이콘 피드즉시 인지 — 로그인 중인 모든 owner/staff 에게 unread 로 노출
감사 로그 (writeActivityLog)tenants/{tid}/activityLogs/activity?caseId=<id>사후 추적 — 발생 시각·오입력 횟수·원인
사건 상세 배너 (PortalLockoutBanner)— (SSR 쿼리)사건 상세 상단알림을 놓친 변호사도 사건을 열면 즉시 확인

NotificationTypeActivityType 모두 portal_access_code_locked 타입을 신설해 일반 사건 알림과 구분한다. 두 레코드의 actorId"system", actorName"시스템" 관례를 따르며, caseId 가 함께 기록되어 알림 클릭이나 활동 로그 필터링으로 해당 사건 상세로 이동할 수 있다.

배너의 자가 해제 동작: PortalLockoutBanner 는 사건 상세 SSR 시점에 해당 사건의 최신 portalTokens 1건을 조회해 status === "revoked" AND accessCodeAttempts >= ACCESS_CODE_MAX_ATTEMPTS 조건을 만족할 때만 렌더링한다. 변호사가 다이얼로그의 "새 코드로 재발급" 을 누르면 새 토큰이 생성되면서 기존 잠긴 토큰이 더 이상 "최신" 이 아니게 되고, 다음 페이지 로드에서 배너가 자동으로 사라진다. 별도 상태 리셋 코드 없이 쿼리 순서만으로 self-healing 이 성립한다.

쓰기 실패 처리: 알림/로그 쓰기는 Firestore 트랜잭션 에서 Promise.allSettled 로 수행한다. 기록 쪽이 일시적 오류로 실패해도 의뢰인에게 돌아가는 "자동 비활성화되었습니다" 응답과 토큰 revoke 처리는 정상 진행된다.

3. 세션 관리

Firebase Auth를 사용하지 않는다. 서버에서 암호화된 세션 쿠키를 직접 관리한다.

세션 쿠키 (iron-session 또는 JWT 암호화)
┌──────────────────────────────┐
│ portal_session │
│ tenantId: string │
│ caseId: string │
│ clientName: string │
│ tokenId: string │ ← 원본 토큰 참조 (revoke 체크)
│ issuedAt: number │
│ expiresAt: number (30일) │
└──────────────────────────────┘

- 매 접속 시 expiresAt을 현재 +30일로 갱신 (sliding window)
- 토큰이 revoke되면 세션도 무효 (tokenId로 검증)

4. 데이터 모델

4.1 portalTokens (신규, portalInvites 대체)

tenants/{tenantId}/portalTokens/{token}
├ tenantId: string
├ caseId: string
├ clientName: string ← 공동원고면 "홍길동 외 2인" 같은 자유 문자열
├ contact?: string ← 선택. 의뢰인 이메일 — 향후 알림 등 확장 여지
├ status: "active" | "revoked"
├ accessCode: string ← 4자리 숫자. 평문 저장 (재조회·재전송 목적)
├ accessCodeAttempts?: number ← 누적 오입력. 임계치 도달 시 자동 revoke
└ createdAt: Timestamp ← 만료 없음. revoke 는 사무소 수동 또는 오입력 자동
  • 같은 사건·의뢰인 이름으로 새 토큰 발급 시 기존 토큰 status → "revoked"
  • 한 사건에 공동원고 여러 명이 있으면 하나의 토큰 + 코드를 공유해 편하게 열람 (clientName 은 "홍길동 외 2인" 처럼 표시용 문자열)
  • 변호사는 다이얼로그에서 "새 코드로 재발급" 클릭 시 기존 토큰을 revoke 하고 새 URL + 새 4자리 코드를 발급받을 수 있음

4.2 messages (이동: tenant-level → case-level)

변경 전: tenants/{tenantId}/messages (clientPhone 기반)
변경 후: tenants/{tenantId}/cases/{caseId}/messages/{messageId}
├ from: "client" | "staff"
├ senderName: string
├ text: string
├ readAt?: Timestamp ← 상대방 읽은 시간
└ createdAt: Timestamp

4.3 cases (필드 변경)

제거: clientId (Firebase Auth UID — 더 이상 존재하지 않음)
유지: clientName, clientEmail, clientPhone (의뢰인 연락처)
추가: portalTokenId?: string (현재 활성 토큰 참조, 선택적)

4.4 activity logs (추가)

tenants/{tenantId}/cases/{caseId}/logs/{logId}
기존 타입에 추가:
├ type: "client_portal_access" ← 의뢰인 포털 접속
├ type: "client_view_document" ← 서류 열람
├ type: "client_download_document" ← 서류 다운로드
├ type: "client_send_message" ← 대화 전송
├ clientName: string
└ createdAt: Timestamp

5. UserRole 변경

변경 전: "owner" | "staff" | "client"
변경 후: "owner" | "staff"

파급 영향

파일변경
types/auth.tsUserRole에서 "client" 제거
types/tenant.tsMemberRole = UserRole (자동 반영)
lib/firebase/auth.tsVALID_ROLES에서 "client" 제거
(workspace)/layout.tsxrole === "client" redirect 제거
(portal)/layout.tsxFirebase Auth → 포털 세션 쿠키 체크로 전환
requireStaffSession()client 체크 불필요 (간소화)
proxy.ts/portal-invite 제거, /portal/view 추가
firestore.rulesisClientOfCase(), isClientOfParentCase() 제거
firestore.rulesmessages client 규칙 제거 (서버 사이드 전용)
components/layout/PLAN_LABEL에서 client 관련 제거
Storybookclient role 관련 스토리 업데이트
E2E포털 관련 테스트 전면 재작성

6. 알림 발송 모듈 (범용) — ⏳ 향후 설계안, 현재 미구현

미착수

본 섹션(6.1~6.4)은 향후 카카오톡 알림톡 도입 시 참고할 설계 초안입니다. 현재 SOLAPI / NHN 비즈메시지 연동은 구현되지 않았으며, 플랜 만료 리마인더 등 서비스 알림은 이메일(Resend)로만 발송합니다.

현재 상태 (2026-04-10 이후): 포털 폴백 인증은 기존 6자리 이메일 OTP 를 걷어내고 4자리 숫자 접속 코드로 교체되었습니다. 변호사가 포털 URL 과 코드를 카카오/문자로 직접 전달하며, 코드는 평문으로 Firestore 에 저장되어 언제든 재조회·재전송할 수 있습니다. 만료는 없고 revoke 는 사무소 수동 또는 오입력 10회 누적 시 자동입니다. 포털 토큰 스키마는 contact 가 optional 로 바뀌었고, 대신 accessCode / accessCodeAttempts 필드가 추가되었습니다. 플랜 만료 리마인더는 여전히 이메일(Resend) 로 발송됩니다. SMS 구현 대신 후속 Phase 에서는 카카오톡 알림톡(SOLAPI/NHN 비즈메시지 등)을 채택할 예정이며, 아래 섹션은 해당 도입 시 참고할 설계안으로 보존합니다.

6.1 서비스 선정: SOLAPI (구 CoolSMS)

기준SOLAPI
SMS + 알림톡단일 SDK 통합
TypeScript네이티브 (solapi npm)
Cloud Functions호환
알림톡 실패 → SMS자동 fallback
비용 (월 500건)약 6,500원

대안: 건당 단가 최우선 시 Aligo (REST API 직접 호출)

6.2 카카오 알림톡 설정 절차

  1. 카카오 비즈니스 채널 개설 — 사업자등록증 필요, 심사 1~3 영업일
  2. 채널 설정 — 검색 허용 ON, 홈 공개 ON, 고객센터 정보 입력
  3. SOLAPI에 채널 연동 — PFID 등록
  4. 템플릿 등록 + 심사 — 카카오 검수 1~3 영업일. 정보성 메시지만 허용
  5. 발송 — 승인된 템플릿 기반

6.3 범용 알림 모듈 설계

lib/messaging/
types.ts — MessageChannel, SendResult 타입
send.ts — sendMessage(channel, to, content) 통합 함수
templates.ts — 알림톡 템플릿 ID + 변수 매핑
providers/
solapi.ts — SOLAPI SDK 래퍼
email.ts — 기존 lib/email/send.ts 연동

발송 우선순위: 알림톡 → SMS → 이메일 (fallback chain)

6.4 필요한 알림톡 템플릿 (초기)

템플릿내용
포털 링크 발송"#{사무소명}에서 사건 진행 현황을 확인하실 수 있습니다. 아래 링크를 눌러주세요."
OTP 인증 코드"인증 코드: #{OTP}. 10분 이내에 입력해주세요."
기일 알림 (향후)"#{사건번호} 다음 기일이 #{일수}일 후입니다."
사건 상태 변경 (향후)"#{사건번호} 사건 상태가 #{상태}로 변경되었습니다."

7. admin 대화 UI (신규)

사건 상세 페이지에 대화 탭 추가:

  • cases/{caseId}/messages 실시간 조회 (onSnapshot)
  • 의뢰인별 대화 스레드 표시 (clientName 기준)
  • 답변 작성 (from: "staff", senderName: 현재 로그인 유저)
  • 읽음 표시 (readAt 업데이트)

8. 삭제 대상

항목이유
UserRole"client"의뢰인은 유저가 아님
CustomClaims client 관련Firebase Auth 불사용
portalInvites 컬렉션portalTokens로 대체
otp-actions.tsportal-token-actions.ts로 대체
portal-invite 페이지/portal/view로 대체
Firestore rules isClientOfCase()서버 사이드 전용
Firestore rules isClientOfParentCase()서버 사이드 전용
Firestore rules messages client 규칙서버 사이드 전용
tenants/{tid}/messages 컬렉션cases/{cid}/messages로 이동
clientId 필드 (cases)토큰 기반 접근, UID 불필요

9. 구현 우선순위

Phase 1: 기반 (필수)

  1. UserRole에서 "client" 제거 + 전체 파급 수정
  2. portalTokens 데이터 모델 + Server Actions
  3. 포털 세션 관리 (iron-session)
  4. 4자리 접속 코드 폴백 (사무소 수동 전달, 만료 없음)
  5. /portal/view 신규 페이지 (토큰 → 접속 코드 → 열람)
  6. 의뢰인 접속 액티비티 로그

Phase 2: 대화 + 알림

  1. cases/{caseId}/messages 서브컬렉션 + 마이그레이션
  2. 포털 대화 UI (의뢰인 측)
  3. admin 대화 탭 (사건 상세)
  4. SOLAPI 연동 (SMS + 알림톡)
  5. 카카오 비즈니스 채널 + 템플릿 심사

Phase 3: 정리

  1. 기존 portalInvites, messages 마이그레이션/삭제
  2. Firestore rules 정리
  3. E2E 테스트 재작성
  4. Storybook 업데이트