본문으로 건너뛰기

ADR 0043 — office 카테고리 만료 자동화

Context

PR #1904~ 에서 legacyDocuments.category 가 3종 (litigation / office / advisory) 으로 분리됐다. office = 사무소 운영 자료 (근로계약·임대차·보험·취업규칙·세무·회계). 이 묶음은 본질적으로 만료일이 있는 문서가 다수다.

사용자 강조선 (2026-05-09): "업무 자동화 + 문서 자동화" — 한 줄 본질의 절반은 "사무소 업무를 더 빠르고 정확하게". litigation 카테고리는 기일·시효 알림이 이미 도메인 레벨로 구현됐지만 (sendHearingReminders · DashboardExpiringStatute · DashboardChildSupportArrears), office 는 만료 추적이 0 이었다.

요구사항:

  1. office 문서 업로드 시 만료일 입력 (선택)
  2. 변호사가 dashboard 첫 화면에서 "이번주 갱신해야 할 계약서" 즉시 식별
  3. D-30 / D-7 / D-1 / D-0 분기에 자동 알림 (이메일 + 인앱) — 알림 폭주 차단
  4. 의도적으로 알림 OFF 한 문서는 존중

Decision

데이터 모델

legacyDocuments.expiryDate?: string (YYYY-MM-DD) + expiryDocReminderEnabled?: boolean 두 필드 추가.

  • category === "office" 일 때만 의미를 가짐 (UI/스키마 강제 분기)
  • expiryDate 미입력 시 알림·chip·widget 모두 silent
  • expiryReminderEnabled default = Boolean(expiryDate) (입력하면 자동 ON)

5 개 wiring chain

ScannedUploadModal (category==office 분기)
└→ CreateScannedLegacySchema (Zod regex)
└→ createScannedLegacyDocAction (Firestore set)
├→ LegacyDocCard (D-NN chip 노출)
├→ DashboardOfficeExpiryWidget (다가오는 만료 5건)
└→ sendOfficeDocExpiryReminders cron (D-30/7/1/0 알림)

분기 발화 — 알림 폭주 차단

shouldFireExpiryReminder(daysUntil) 가 D-30 / D-7 / D-1 / D-0 만 true. 그 외 모든 day 는 false. 매일 발화하면 사용자 fatigue → 의미 있는 4 분기만 발화 (총 4회 + 만료 당일).

Idempotency

cron 은 같은 docId 같은 dDay 에 1회만 알림. notificationId = office-expiry-{docId}-d{dDay} — 기존 notification doc 존재 시 skip.

임박도 분류

type ExpiryUrgency = "expired" | "imminent" | "soon" | "approaching" | "far" | "none";
  • expired (D+N): rose
  • imminent (D-7 이내): orange
  • soon (D-30 이내): amber
  • approaching (D-90 이내): muted
  • far (90+): chip 노이즈 차단 (action 가능 시점만 가시화)

활성화 단계

Cron 은 production 에서 비활성 (사용자 0 동안 비용 0). 활성 조건:

  1. office 카테고리 문서 N건 존재 (tenant DB)
  2. owner 이메일 수신 의사 확인
  3. memory 512MiB 헤드룸 (production sweep 2026-05-04 정합)

활성화 절차: index.ts// export { sendOfficeDocExpiryReminders } 주석 해제 → pnpm --filter functions buildfirebase deploy --only functions:sendOfficeDocExpiryReminders → 첫 09:00 KST 실행 로그 검증.

SSoT 코드 위치

  • apps/web/app/(workspace)/legacy/_lib/expiry-reminder.ts — pure helpers (web 측 SSoT)
  • apps/web/app/(workspace)/legacy/_lib/__tests__/expiry-reminder.test.ts — 21 unit test
  • functions/src/expiry-reminder-pure.ts — Cloud Functions 측 복제 (별도 npm workspace)
  • functions/src/send-office-expiry-reminders.ts — cron
  • apps/web/app/(workspace)/dashboard/_actions/get-upcoming-office-expiries-action.ts — widget 데이터
  • apps/web/app/(workspace)/dashboard/_components/DashboardOfficeExpiryWidget.tsx — widget UI

회귀 가드

  • apps/web/__tests__/expiry-reminder-cross-workspace-drift.test.ts — apps/web ↔ functions/ 의미 동등성 (embed-input-prepare 패턴)
  • apps/web/__tests__/office-expiry-pipeline-invariants.test.ts — 5 wiring chain 정적 grep
  • functions/__tests__/send-office-expiry-reminders-invariants.test.ts — cron 8 invariant
  • functions/__tests__/expiry-reminder-pure.test.ts — 11 unit test

총 51+ 테스트가 의미 변경 1 곳만 누락돼도 PR build fail 보장.

Consequences

긍정

  • 사무소 운영 자료가 dashboard 시각화 + 자동 알림으로 닫힌 루프 완성 ("업무 자동화" 한 줄 본질 절반 ship)
  • 분기 발화 + idempotency 로 알림 폭주 차단 — 사용자 fatigue 0
  • 5 wiring chain 정적 invariant 가 silent failure 차단

부정 / 트레이드오프

  • functions/ 측 helper 복제 (SSoT 위반 형태) — drift 가드로 완화
  • cron 은 비활성 default — 활성화 시점 메모리 baseline 재검증 필요

향후

  • Phase 2: 갱신 후 자동으로 신규 expiryDate 제안 (예: 12개월 임대차 자동 +1년)
  • Phase 3: Pack 4 상속·Pack 5 계약과 합쳐 "사무소 모든 문서 만료" 통합 위젯
  • Phase 4: 만료 임박 시 갱신 contract draft AI 자동 생성 (ADR 0041 두 RAG 결합 활용)v1.1 amendment 로 ship 완료

v1.1 Amendment (2026-05-09 PR #1924~#1927) — Phase 4 갱신 검토 노트

본문 Phase 4 의 향후 항목을 v1.1 으로 ship. 단 "갱신 contract draft 자동 생성" 의 simplified 형태 — 검토 노트 까지만. docx draft 는 후속 Phase 5.

v1.1 Decision

갱신 검토 노트 = AI 가 만료 임박 contract 의 갱신 협상 시 검토할 5±2 포인트 + 협상 권고 + 두 RAG 인용을 JSON 으로 출력. 변호사가 그대로 메모/상담 노트로 활용.

v1.1 Flow

LegacyDocDetail (office + expiryDate + ocrStatus completed|approved 조건 분기)
└→ "갱신 검토 작성" 버튼 → ContractRenewalReviewModal
└→ prepareContractRenewalReviewAction (Server Action)
├→ legacyDoc 조회 (category/expiryDate/redactedText 검증)
├→ embedQueryForMemorySearch ("{caseType} 갱신 협상 검토")
├→ Tenant RAG findNearest (legacyDocuments, self 제외, 5건)
└→ Platform RAG findNearest (publicDocuments, 5건)
└→ reserveAssistAction(1, "docGeneration") — weight 1 차감
└→ generateContractRenewalReview (firebase/ai App Check)
└→ parseRenewalReviewResponse (Zod parse)
├→ 성공: ReviewContent 5 영역 표시 + CopyButton
└→ 실패: refundAssistAction (parse_fail / upstream_5xx) 자동 환불

v1.1 출력 schema

{
summary: string, // 한 줄 요약
reviewPoints: Array<{
title: string,
description: string,
priority: "high" | "medium" | "low",
category: "term" | "payment" | "termination" | "renewal" | "liability" | "other",
}>, // 3~7 항목
negotiationHints: string[], // 0~5 항목
citations: Array<{
sourceType: "tenant" | "platform",
title: string,
snippet: string,
relevance: "high" | "medium" | "low",
}>, // 0~8 항목
}

v1.1 보안

  • PII 무전파: redactedText (PII 마스킹 OCR) 만 사용. originalText 데이터 access 절대 금지 (정적 invariant 강제).
  • cross-tenant 차단: guardedFindNearest 강제. findNearest 직접 호출은 publicDocuments (전 사용자 공유) 1 건만 허용 (정적 invariant 강제).
  • AI 한도: weight 1 차감 (시뮬 한도). 실패 시 자동 환불 (parse_fail / upstream_5xx).

v1.1 한 줄 본질 정합

사무소가 자기 갱신 사례 (Tenant RAG) 로 자기 contract 협상 노트 생성 — "자기 데이터로 자기 AI 학습·활용" 의 office 측 closed-loop 완성.

v1.1 SSoT 코드 위치

  • apps/web/app/(workspace)/legacy/_lib/contract-renewal-review.ts — pure helper + Zod schema + prompt builder
  • apps/web/app/(workspace)/legacy/_lib/__tests__/contract-renewal-review.test.ts — 21 unit test
  • apps/web/app/(workspace)/legacy/_actions/contract-renewal-review-action.ts — Server Action (RAG 결합)
  • apps/web/lib/ai/client.ts generateContractRenewalReview — Gemini wrapper (App Check)
  • apps/web/app/(workspace)/legacy/_components/ContractRenewalReviewModal.tsx — UI Modal
  • apps/web/app/(workspace)/legacy/_components/LegacyDocDetail.tsx — 진입 버튼 + Modal 마운트

v1.1 회귀 가드

  • apps/web/__tests__/contract-renewal-review-pipeline-invariants.test.ts — 7 검사 (5 wiring + 2 보안)
  • 위 21 unit test + 기존 51+ ADR 0043 v1.0 테스트 = 총 80+ 테스트가 PR build fail 보장

v1.1 후속 (Phase 5+)

  • Phase 5: 검토 노트 → docx 갱신 draft 자동 생성 (ADR 0041 generateDocxAction 분기)v1.2 Phase 5 lite ship 완료 (markdown 본문). 5b (.docx) 후속.
  • Phase 6: 검토 노트 변호사 피드백 capture → exemplar 학습 누적 (ADR 0018 패턴 답습)
  • Phase 7: D-30/D-7 알림 이메일에 "갱신 검토 작성" 1 클릭 deep link

v1.2 Amendment (2026-05-10 PR #1929~#1932) — Phase 5 lite 갱신 초안 markdown

v1.1 Phase 4 (검토 노트) 결과를 입력받아 갱신 contract markdown 본문 + 변경 highlights 를 AI 가 생성. 변호사가 CopyButton 으로 즉시 워드프로세서 활용.

v1.2 Decision

Phase 5 lite = markdown 본문 출력. 5b (.docx 자동 다운로드) 는 후속 PR.

이유:

  • caseId 의존 0 — 만료 contract 는 caseId 없을 수 있어 generateDocxAction 우회 필요
  • .docx 템플릿 5종 (임대차/근로/보험/취업규칙/주차장) 마다 placeholder 다름 → simplified
  • markdown 으로도 변호사 즉시 활용 가능 (워드 / 한글 / 메일 본문 붙여넣기)

v1.2 Flow

ContractRenewalReviewModal (Phase 4 ReviewContent 표시 후)
└→ "갱신 초안 작성" CTA 버튼 클릭 (DraftStartCta)
└→ prepareContractRenewalDraftAction (Server Action)
├→ legacyDoc 재조회 (category/expiryDate/redactedText 가드)
├→ input.review 강제 ContractRenewalReviewResponseSchema.parse
│ (클라이언트 임의 review payload 차단)
└→ buildRenewalDraftPrompt (메타 + redactedText + Phase 4 review)
└→ reserveAssistAction(1) — weight 1 추가 차감
└→ generateContractRenewalDraft (firebase/ai App Check + json mime)
└→ parseRenewalDraftResponse (Zod parse)
├→ 성공: DraftContent 3 영역 (헤더 + highlights + markdown)
└→ 실패: refundAssistAction (parse_fail / upstream_5xx)

v1.2 출력 schema

{
draftTitle: string, // "근로계약서 (2026-2027 갱신)"
fullDraftMarkdown: string, // markdown 본문 100~20000 자
changesHighlights: Array<{ // 최대 20
section: string, // "제3조 (임대료)"
changeType: "modified" | "added" | "removed",
summary: string,
reason?: string,
}>,
newExpiryDate: string, // YYYY-MM-DD, 2020~2099
carryOverNote: string, // "본 갱신 계약은 ..."
}

v1.2 보안

  • PII 무전파: redactedText 만 사용 (originalText 데이터 access 0). 정적 invariant 강제.
  • review 강제 검증: 클라이언트가 임의 review payload 주입 못 하게 ContractRenewalReviewResponseSchema.parse 강제.
  • AI 한도: weight 1 추가 차감 (Phase 4 와 별개). 실패 시 자동 환불.

v1.2 SSoT 코드 위치

  • apps/web/.../legacy/_lib/contract-renewal-draft.ts — pure helper + Zod schema + prompt builder + defaultRenewedExpiryDate
  • apps/web/.../legacy/_lib/__tests__/contract-renewal-draft.test.ts — 21 unit test
  • apps/web/.../legacy/_actions/contract-renewal-draft-action.ts — Server Action (review 강제 검증)
  • apps/web/lib/ai/client.ts generateContractRenewalDraft — Gemini wrapper (responseMimeType json + maxOutputTokens 6000)
  • apps/web/.../legacy/_components/ContractRenewalReviewModal.tsx — DraftStartCta + DraftPendingState + DraftContent 3 sub-component

v1.2 회귀 가드

  • apps/web/__tests__/contract-renewal-draft-pipeline-invariants.test.ts — 7 검사 (5 wiring + 2 보안)
  • 위 21 unit test + v1.0 51+ + v1.1 7 + v1.2 7 = 총 100+ 테스트가 PR build fail 보장

v1.2 후속 (Phase 5b+)

  • Phase 5b: .docx 자동 다운로드 (legacy 전용 generateRenewalDocxAction — caseId 없는 경로)
  • Phase 5c: Tiptap 에디터로 markdown 직접 편집 + finalize → legacyDocuments 신규 항목 추가 (사무소 기억 누적)

본문 v1.0 cron 의 향후 항목 (Phase 7 — D-30/D-7 알림 이메일 deep-link) ship 완료 + e2e UI 진입 회귀 보호.

v1.3 Decision

이메일 받자마자 변호사가 1 클릭으로 검토 모달 진입 — email → modal closed-loop 완성.

v1.3 Flow

sendOfficeDocExpiryReminders cron (매일 09:00 KST, D-30/7/1/0 분기)
└→ buildExpiryReminderHtml — "AI 갱신 검토 작성하기" CTA 버튼 추가
└→ href = buildRenewalDeepLink(legacyDocId)
└→ "{WEB_BASE_URL}/legacy/{docId}?renew=1"

이메일 수신자 클릭 → /legacy/{docId}?renew=1
└→ LegacyDocDetail useSearchParams("renew")==="1" 분기
└→ ContractRenewalReviewModal 자동 열림
└→ Phase 4 검토 노트 → Phase 5 갱신 초안 markdown

v1.3 핵심 결정

  • pure helper 별도 파일 (email-deep-link.ts) — send-office-expiry-reminders.tsgetFirestore() 가 module load 시 실행돼 test 환경 import 시 throw → pure helper 분리로 vitest 가능
  • encodeURIComponent — path 주입 차단 (Firestore doc id 가 일반적으로 안전하지만 방어선)
  • NEOHOLLO_WEB_BASE_URL env override — staging/dev 도메인 차별
  • 3-pillar 정합 (functions/web/dashboard widget) — 모두 ?renew=1 키 일관 (정적 invariant 강제)

v1.3 e2e UI 진입 회귀 보호 (Step 3 ship)

e2e/regression-office-renewal-flow.spec.ts (3 시나리오):

  1. dashboard 위젯 빈 상태 (office 0건)
  2. 시드 office 문서 → widget item ?renew=1 → Modal 자동 열림 → CTA 노출
  3. legacy detail 직접 진입 → "갱신 검토 작성" 버튼 → Modal 열림

AI 호출 (Gemini) 은 mock 안 함 — UI 진입 + CTA 클릭 가능까지만 검증 (regression-doc-generate-entry.spec.ts 패턴 답습). DraftContent 노출까지의 full flow 는 Phase 5b 후속 (mock spec).

v1.3 SSoT 코드 위치

  • functions/src/email-deep-link.ts — pure helper (buildRenewalDeepLink)
  • functions/src/send-office-expiry-reminders.ts buildExpiryReminderHtml — CTA 버튼 결합
  • functions/__tests__/build-renewal-deep-link.test.ts — 6 unit test
  • e2e/regression-office-renewal-flow.spec.ts — 3 시나리오
  • e2e/helpers/firestore.ts seedOfficeExpiryLegacyDoc — UI 노출 조건 시드

v1.3 회귀 가드

  • apps/web/__tests__/email-deeplink-renewal-invariants.test.ts — cross-workspace drift (functions/web/dashboard widget 3-pillar ?renew=1 일관)
  • functions/__tests__/build-renewal-deep-link.test.ts (6) + send-office-expiry-reminders-invariants.test.ts (8 → 12, +4 검사)
  • e2e 3 시나리오 자체가 회귀 spec
  • 누적 100+ 테스트 + e2e

v1.3 후속 (Phase 7+)

  • Phase 7b: 이메일 CTA 클릭 후 자동 OAuth 우회v1.4 lite ship (안내 문구). 자동 redirect 는 Phase 7b-full 후속
  • Phase 7c: 알림 클릭 stat trackingv1.4 ship (?from=email-d{N} 부착 + Firestore 누적)

v1.4 Amendment (2026-05-10 PR #1936~#1939) — click-through stat + .md 다운로드 + lite 안내 + 다중 e2e

5 후속 트랙 중 4 ship. magic link 자동 redirect 만 보안 검토 후속 (Phase 7b-full).

v1.4 Decision

  • 7c (#1936): deep-link 에 ?from=email-d{N} / ?from=dashboard 부착 → tenants/{tid}/renewalLinkClicks/{auto-id} 누적. ops 후속 분석 source.
  • 5b (#1937): DraftContent 헤더에 ".md 다운로드" 버튼 — Blob + URL.createObjectURL 외부 라이브러리 0. 메타 + highlights + 본문 + PII footer 결합 한 파일.
  • 7b lite (#1938): 이메일 본문에 "로그인 후 재클릭" 안내. 자동 redirect 는 후속.
  • e2e mock (#1939): 4 시나리오 (CTA 안내 + Modal close→재오픈 + 다중 시드 widget 정렬 + click-through silent). AI mock 풀 flow 는 firebase/ai SDK level 변경 필요 → 후속.

v1.5 Amendment (2026-05-10 PR #1940~) — 갱신 finalize → legacyDocuments 누적 (closed-loop 종착점)

본문 v1.2 후속의 Phase 5c (Tiptap 편집 → finalize → legacyDocuments 신규 항목 누적) ship 완료. lite 형태 — Tiptap 대신 textarea + 저장.

v1.5 Decision

closed-loop 종착점: 사무소가 자기 contract 의 갱신본을 자기 사무소 기억으로 누적 → 다음 갱신 시 Tenant RAG 가 본 갱신 사례를 참고 → 자기 데이터 → 자기 AI 학습·활용 한 줄 본질 완성.

v1.5 Flow

DraftContent ("갱신 초안 markdown" 표시 후)
└→ "편집" 토글 — markdown textarea 변환 (변호사 직접 수정 가능)
└→ "사무소 기억으로 저장" 버튼 클릭
└→ finalizeRenewalDraftAction (Server Action)
├→ requireFirmMemoryAccess + parentLegacyDocId 검증 (소스 contract)
├→ category=office + scannedMeta 부모 승계
├→ FinalizeRenewalDraftInputSchema Zod (50KB 한도 + 만료일 2020~2099)
└→ tenants/{tid}/legacyDocuments/{auto-id}:
sourceType: "authored"
category: "office"
ocrStatus: "approved" (변호사 작성 — OCR 우회)
redactedText: editedMarkdown (Storage 우회 — inline 저장)
parentLegacyDocId: 부모 contract docId
expiryDate: newExpiryDate (chain 보존)
scannedMeta: 부모 승계
└→ writeActivityLog "갱신 contract 사무소 기억 누적"
└→ embed-legacy-doc onCreate trigger 자동 임베딩 (ADR 0015)

v1.5 핵심 결정

  • textarea + 저장 형태 (Tiptap 통합은 Phase 5d 후속) — 1 PR closed-loop 우선
  • storagePath 빈 문자열 + redactedText 에 markdown inline — Storage 우회 (변호사 작성 본문이라 OCR/원본 분리 의미 없음)
  • ocrStatus: "approved" — OCR 파이프라인 우회 (Phase 4/5 결과는 이미 "검토·정제됨")
  • parentLegacyDocId — 갱신 chain 추적 (다음 갱신 시 ancestor 추적·교차 참조)
  • scannedMeta 부모 승계 — clientName/caseType 동일 유지 → 검색·분류 정합

v1.5 SSoT 코드 위치

  • apps/web/.../legacy/_actions/finalize-renewal-draft-action.ts — Server Action
  • apps/web/types/legacy-document.tsparentLegacyDocId?: string 필드 추가 (LegacyDocument + Serialized)
  • apps/web/app/(workspace)/legacy/_lib/{helpers.ts,useLegacyDocs.ts} — serializer 동기화
  • apps/web/app/(workspace)/legacy/_components/ContractRenewalReviewModal.tsx — textarea 편집 + 저장 버튼

v1.5 회귀 가드

  • apps/web/__tests__/finalize-renewal-draft-pipeline-invariants.test.ts — 7 검사 (5 wiring + 2 closed-loop)
  • 누적 110+ 테스트 (v1.0 51+ + v1.1 28 + v1.2 28 + v1.3 6 + v1.4 5 + v1.5 7)

v1.5 후속 (Phase 5d+)

  • Phase 5d: Tiptap 에디터 통합v1.7 lite ship (markdown preview 토글). Tiptap 풀 통합은 Phase 5d-full 후속
  • Phase 5e: parent-child 관계 시각화v1.6 ship (chip + RenewalChainPanel)
  • Phase 5f: Chronicle 갱신 chain 시각화v1.6 ship (Dashboard 사무소 풍경 누적 stat)
  • Phase 7b-full: 이메일 CTA 자동 OAuth 우회 (보안 검토 후)
  • e2e mock 풀 flow: firebase/ai SDK level 가로채기 또는 dev hook (production 코드 오염 검토)

v1.6 Amendment (2026-05-10 PR #1941~#1942) — 갱신 chain 시각화

v1.6 Decision

v1.5 finalize 가 parentLegacyDocId chain 만들었지만 UI 가시화 0 → v1.6 에서 LegacyDocCard chip + RenewalChainPanel + Dashboard 누적 stat closed-loop 시각화.

v1.6 신규

  • #1941 Phase 5e (parent-child):
    • getRenewalChainAction — 부모 + 자식 병렬 조회 (where parentLegacyDocId == id, expiryDate ASC, MAX 10)
    • RenewalChainPanel — 부모 (ArrowUp) + 자식 (ArrowDown) 시각화. 0건 silent hide
    • LegacyDocCardparentLegacyDocId 있으면 "↪ 갱신본" chip + tooltip
    • LegacyDocDetail — RenewalChainPanel 마운트
  • #1942 Phase 5f (Chronicle stat):
    • getRenewalChainStatActionsourceType=authored + parentLegacyDocId 카운트 (.select PII 안전)
    • DashboardChronicleSummaryCard — chronicle + renewal stat Promise.all 병렬
    • 0건 hide / > 0 노출 + "Tenant RAG 자동 참고" 한 줄 본질 안내

v1.7 Amendment (2026-05-10 PR #1943) — markdown preview 토글 (Phase 5d lite)

v1.7 Decision

Tiptap 풀 통합은 ProseMirror-markdown 어댑터 의존성 + 큰 설정 변경 → 본 PR 은 자체 simple markdown → HTML 변환 + 3 모드 토글 (원본/편집/미리보기). Tiptap 풀 통합은 후속 (Phase 5d-full).

v1.7 신규

  • simple-markdown.ts pure helper — 외부 라이브러리 의존 0
    • 보안: HTML escape 우선 → escape 된 본문 위에 한정 패턴만 markdown → HTML 변환
    • 지원: 헤더 (h1/h2/h3) · 굵게 · 기울임 · 인라인 코드 · 코드 블록 · 리스트 · 인용 · 가로줄
    • 미지원 (의도): 링크 (URL 보안 검증 비용), 이미지 (외부 노출 위험), HTML inline (직접 작성 차단)
  • ContractRenewalReviewModalviewMode tri-state (raw/edit/preview) + ViewModeTab sub-component
  • preview 모드 — renderSimpleMarkdown + dangerouslySetInnerHTML (사전 escape 보장)

v1.7 보안 정합

  • HTML escape 우선 → script/iframe/onclick/javascript: 모두 escape 된 텍스트로 노출 (실제 태그 0)
  • XSS 회귀 가드 unit test — script tag, onclick attr, image URL, javascript: URL 4종

v1.7 후속 (Phase 5d-full)

  • Tiptap StarterKit + ProseMirror-markdown 통합 — revision history + visual editing
  • 또는 marked + DOMPurify (외부 라이브러리 가벼운 통합)

v1.8 Amendment (2026-05-10 PR #1944) — Phase 7b-full redirect_to 인프라

v1.4 phase 7b lite (이메일 안내 문구) 의 후속 — ?redirect_to query 보존 인프라 ship.

v1.8 Decision

미인증 사용자가 명시적 /login?redirect_to=/legacy/{id}?renew=1 URL 클릭 시 로그인 후 그 URL 로 navigate. open redirect 차단 위해 same-origin + path 화이트리스트 강제.

v1.8 신규

  • apps/web/lib/auth/safe-redirect.ts (pure helper)
    • safeRedirectTo(raw)/로 시작 + 화이트리스트 path + 1024 char 한도 + protocol-relative/javascript:/data:/file: 차단
    • resolveAuthenticatedRedirect(raw) — safe 검증 통과 시 그 URL, 실패 시 /dashboard fallback
    • buildLoginRedirectUrl(base, path)/login?redirect_to=... URL 빌드 (외부 deep-link 용)
  • decideAuthRedirect(decoded, redirectTo?) — redirect_to 인자 추가, tenantId 부재 시 onboarding 우선
  • createSessionAction(idToken, rawRedirectTo?) — server-side safeRedirectTo 재검증 강제
  • LoginFormuseSearchParams("redirect_to") 받아 양 흐름 (Google + Email) 에 전달

v1.8 보안 정합

  • server-side 재검증 — client 검증 우회 차단 (createSessionAction 안에서 safeRedirectTo)
  • 화이트리스트 path — /dashboard /cases/ /legacy/ /docs/ /clients/ /portal/ /settings /onboarding /calendar /activity /library
  • DoS 방어 — 1024 char 초과 차단
  • 위험 scheme 차단 — protocol-relative + javascript:/data:/file:

v1.8 알려진 한계 (후속 PR)

  • workspace layout redirect("/login") 이 path 모름 (Next.js 16 server component 제약) → 미인증 deep-link 클릭 시 redirect_to 자동 부착 못 함
  • cron 이메일 + dashboard widget URL 형식 변경 안 함 — 인증된 사용자 흐름은 그대로 작동, 미인증은 v1.4 lite 안내 의존
  • 진정한 자동 redirect 보존: middleware 또는 headers() x-invoke-path 사용 (CLAUDE.md middleware 신설 금지 정책 + internal 헤더 의존 위험)

v1.8 후속

  • workspace layout 변경 없이 deep-link 자동 보존하는 방법 — cookie 기반 path 보존 + edge case 검토
  • 또는 cron 이메일 자체가 /login?redirect_to=... 형식으로 전송 (인증된 사용자도 redirect 흐름 — UX flash 검토)

v1.9 Amendment (2026-05-10 PR #1945) — e2e mock 풀 flow

v1.4 (#1939) 다중 entry e2e 의 후속. firebase/ai SDK Gemini 호출을 Playwright page.route 로 가로채 ReviewContent + DraftContent 까지 풀 flow 검증 시도.

v1.9 신규

  • e2e/regression-office-renewal-ai-mock.spec.ts (1 시나리오)
    • 4 endpoint 광범위 가로채기 (firebasevertexai/firebaseml/aiplatform/generativelanguage)
    • MOCK_REVIEW_RESPONSE / MOCK_DRAFT_RESPONSE fixture (PII 마스킹 보존)
    • Gemini 응답 형식 (candidates[].content.parts[].text) 으로 fulfill
    • silent skip 패턴 — endpoint 변경 시 production 영향 0
  • e2e-renewal-ai-mock-spec-invariants.test.ts (6 검사) — endpoint + fixture Zod 정합

v1.9 한계 (후속)

  • firebase/ai SDK internal endpoint 변경 시 가로채기 실패
  • 진정한 안정성: dev hook (NEXT_PUBLIC_E2E_MOCK_AI) 패턴 또는 getAiProvider DI

v1.10 Amendment (2026-05-10 PR #1946) — Phase 5d-full Tiptap markdown editor

v1.7 (#1943) preview 토글의 후속. textarea 편집 모드를 Tiptap StarterKit + prosemirror-markdown 풀 통합으로 대체.

v1.10 Decision

Tiptap WYSIWYG markdown 편집기:

  • StarterKit 의 Bold/Italic/Heading/List/Quote/CodeBlock + Undo/Redo
  • markdown ↔ ProseMirror doc 자동 변환 (prosemirror-markdown)
  • 툴바 — 키보드 단축어 (Cmd+B/I/Z/Shift+Z) + 시각 버튼
  • prose 스타일 (Tailwind typography) + max-h-96 overflow

v1.10 신규

  • prosemirror-markdown ^1.13.4 설치
  • apps/web/.../legacy/_components/TiptapMarkdownEditor.tsx:
    • useEditor + StarterKit + JSONContent 타입
    • markdownToProseMirrorJson / proseMirrorJsonToMarkdown helper (parse 실패 시 plain text fallback)
    • ToolbarButton + ToolbarSeparator sub-component
    • immediatelyRender false (Next.js 16 SSR 정합)
    • disabled prop 동기화 + initialMarkdown reset 동기화
  • ContractRenewalReviewModal viewMode="edit" 분기에서 textarea → TiptapMarkdownEditor

v1.10 보안 정합

  • Tiptap StarterKit 이 sanitized HTML 만 허용 (script/iframe noop)
  • prosemirror-markdown serializer 가 PII 마스킹 토큰 [MASK_*] plain text 그대로 보존
  • markdown round-trip (input → ProseMirror → output) — 의미 변경 0

v1.10 회귀 가드

  • tiptap-markdown-editor-invariants.test.ts (10 검사):
    • useEditor + StarterKit + prosemirror-markdown import
    • markdown ↔ ProseMirror JSON 변환 helper
    • 툴바 7 종 + Undo/Redo
    • onChange 콜백 + immediatelyRender false + prose
    • Modal 통합 정합 + editedMarkdown setState
    • package.json 의존성 정합 (prosemirror-markdown 1.13+ + @tiptap/react + starter-kit)

v1.10 후속

  • revision history 풀 통합 (yjs / collab 또는 자체 history) — 변호사 변경 추적
  • 추가 extension (Link / TaskList / Table) — 사무소 contract 형식 정합 시
  • 협업 모드 (multi-user 동시 편집) — Phase 5e 와 합쳐 검토

v1.12v1.15 Amendment (2026-05-10 PR #1949#1952) — 4 후속 트랙

v1.7+v1.10 후속 명시 — 4 트랙 ship.

v1.12 (#1949) e2e mock dev hook

  • lib/ai/e2e-mock-guard.ts — 다단 가드 (NODE_ENV + NEXT_PUBLIC_E2E_MOCK_AI + window check)
  • client.ts generateContractRenewalReview/Draft 가 가드 통과 시 hardcoded mock 응답
  • 보안: production build 에서 mock 활성 0, server-side 보호, Zod 정합 검증

v1.13 (#1950) Tiptap Link/Table/TaskList extension

  • @tiptap/extension-link / table / table-row / table-cell / table-header / task-list / task-item (3.23.1)
  • 전체 @tiptap/* 3.23.1 통일 (type 충돌 차단)
  • Link openOnClick=false + http(s) 검증
  • Table 3×3 with header
  • TaskList nested 체크리스트
  • 툴바 +3 버튼 (링크 / 표 삽입 / 체크리스트)

v1.14 (#1951) 갱신 본문 revision history

  • _actions/renewal-revisions-actions.ts — 3 Server Action
    • saveRenewalRevisionAction (sourceType=authored 한정 + Zod 50KB)
    • getRenewalRevisionsAction (savedAt DESC, MAX 30)
    • getRenewalRevisionMarkdownAction (단건 viewer)
  • finalizeRenewalDraftAction 가 신규 doc 생성 즉시 첫 revision 자동 저장
  • RenewalRevisionsPanel UI — sourceType=authored 분기 + viewer modal (read-only + Copy)

v1.15 (#1952) Pack 4/5 만료 통합 grouping

  • UpcomingDeadlinesGroup wrapper — 기존 widget 3 종 시각 grouping
    • DashboardExpiringStatute (Pack 1~5 시효)
    • DashboardChildSupportArrears (Pack 2 양육비)
    • DashboardOfficeExpiryWidget (사무소 운영 만료)
  • group header — "곧 만료·시효" + Pack 1~5 안내
  • 통합 unified widget 신설은 후속 (데이터 source 다름 — legacyDocuments vs cases)

v1.12~v1.15 검증

  • 누적 270+ 테스트 (v1.11 250 + v1.12 10 + v1.13 5 + v1.14 8 + v1.15 4)
  • apps/web: 541 files / 7129 tests
  • 0 회귀, 0 type/lint 에러

v1.16v1.19 Amendment (2026-05-10 PR #1953#1956) — 후속 후보 4 트랙

v1.16 (#1953) saveRenewalRevisionAction UI 통합

  • v1.14 Server Action 을 RenewalRevisionsPanel 과 wire
  • "새 snapshot" 버튼 — 변경 요약 input + 본문 textarea (10자~50KB)
  • 성공 시 list refresh + editing 종료

v1.17 (#1954) 통합 unified widget

  • getUnifiedUpcomingDeadlinesAction — statute + office 두 source 병렬
  • DashboardUnifiedDeadlinesWidget — KindBadge + status chip + 더 보기 + 0건 hide
  • DashboardWidgets UpcomingDeadlinesGroup 최상단에 마운트 (기존 widget 보존)

v1.18 (#1955) Pack 4/5 contractualDeadline 필드

  • Case.contractualDeadline?: string | null (시효와 별개 — 계약 본문 이행 기한)
  • contractualDeadlineReminderEnabled?: boolean (default true)
  • listContractualDeadlinesAction — tenant scope + window + 본문 비반환
  • getUnifiedUpcomingDeadlinesAction 세 source 통합 (statute + office + contractual)
  • KindBadge "contractual-deadline" — Handshake icon (emerald)

v1.19 (#1956) 협업 모드 lite (UI 진입점 reservation)

  • 풀 협업 모드 (yjs / WebSocket sync server) 는 후속 PR 분리
  • 본 PR scope = UI 진입점 + 사용자 인지 + 향후 호환:
    • TiptapMarkdownEditor 툴바 — disabled "협업 모드" 버튼 (UsersThree icon)
    • 클릭 시 alert "후속 PR 예정 (yjs sync server 필요)"
    • editor footer — "단일 사용자 편집 중 — 다중 사용자 후속"

v1.16~v1.19 검증

  • 누적 290+ 테스트 (v1.15 270 + v1.16 1 + v1.17 6 + v1.18 6 + v1.19 4)
  • apps/web: 544 files / 7146 tests
  • 0 회귀, 0 type/lint 에러

v1.16~v1.19 후속 (Phase 6+)

  • v1.18 후속: 사건 등록/편집 UI 에 contractualDeadline 입력 필드 추가
  • v1.18 후속: cron 알림 sendContractualDeadlineReminders (D-30/7/1/0)
  • v1.19 후속: yjs / @tiptap/extension-collaboration 풀 통합 + WebSocket sync server (또는 y-firestore)
  • v1.19 후속: presence indicator (legacyDocuments/{id}/presence subcollection — 활성 사용자 list)

v1.20 Amendment (2026-05-10 PR #1957) — 분리 widget 보존 결정 + 측정 인프라

v1.17 unified widget 신설 후 기존 분리 widget 3 종 (DashboardExpiringStatute / DashboardChildSupportArrears / DashboardOfficeExpiryWidget) 유지 여부 결정.

v1.20 Decision

보존 (deprecate 안 함) — 사용자 사용 패턴 측정 후 데이터 기반 결정.

v1.20 사유

보존 근거:

  • 사용자 마이그레이션 시간 — 새 widget 학습/적응 기간 필요
  • UX 선호 차이 — 일부 변호사는 분리 view 선호 (사건 vs 문서 vs 양육비 별도 인지)
  • deprecate 비용 — 9 e2e 시나리오 + 7 invariant + ops dashboard 의존성
  • 데이터 기반 의사결정 — 단순 신설보다 사용 패턴 30일 측정 후 deprecate 신호 확인

deprecate 신호 (후속 데이터 측정 후):

  • "분리 widget 클릭 0%" 30일 연속 + unified widget click-through 우세
  • 또는 사용자 피드백 직접 — "통합 view 만으로 충분"

v1.20 측정 인프라 (후속 PR)

  • v1.7c click-through stat 패턴 답습 — tenants/{tid}/dashboardWidgetClicks/{eventId}
  • widget kind enum: "expiring-statute" | "child-support-arrears" | "office-expiry" | "unified-deadlines"
  • ops dashboard 분석 — 30일 누적 click rate 비교
  • deprecate 결정 시 ADR 0043 v1.21 amendment + e2e/invariant/widget 코드 일괄 제거 PR

v1.20 보안·정합

  • 4 widget 모두 자체 0건 hide 패턴 유지 (UX 노이즈 0)
  • UpcomingDeadlinesGroup wrapper 가 시각 grouping (v1.15)
  • KindBadge 시각 분리 — 사용자가 분리/통합 view 둘 다 인지 가능

v1.20 후속 (Phase 7+)

  • 측정 인프라 ship — dashboardWidgetClicks 컬렉션 + click handler 추가
  • ops dashboard widget 분석 카드 — 30일 누적 click rate per kind
  • 30일 데이터 누적 후 deprecate 결정 PR (v1.25 또는 후속)

v1.21v1.24 Amendment (2026-05-10 PR #1958#1961) — 후속 후보 5 트랙 ship

v1.16~v1.19 후속의 5 트랙 (widget retention ADR + contractualDeadline UI + cron + presence + yjs lite) 모두 ship.

v1.21 (#1958) contractualDeadline UI 입력

  • updateCaseContractualDeadlineAction Server Action — Zod + ownership gate + 화이트리스트 + activity log
  • ContractualDeadlineSection UI — 표시/편집/삭제 + Handshake emerald
  • CaseData.contractualDeadline? + reminderEnabled? 타입 확장
  • CaseDetailClient 마운트 (CaseSummaryCards 직후)

v1.22 (#1959) sendContractualDeadlineReminders cron

  • 비활성 cron — 0 9 * * * Asia/Seoul + 512MiB
  • collectionGroup cases + where contractualDeadline == date 4 분기 (D-30/7/1/0)
  • soft-delete + reminderEnabled=false skip
  • idempotency contractual-deadline-{caseId}-d{dDay}
  • 한국어 본문 + Pack 4/5 + "시효와 별개" 명시 + buildLoginRedirectUrl 경유 deep-link

v1.23 (#1960) presence indicator

  • usePresence hook — Firestore subcollection 활성 사용자 추적
  • heartbeat 25초 + stale 60초 cutoff + onSnapshot real-time
  • mount setDoc / interval updateDoc / unmount deleteDoc cleanup
  • PresenceIndicator UI — 혼자/다중 분기 + avatar overflow + self ring
  • LegacyDocDetail 마운트

v1.24 (#1961) yjs Collaboration prop reservation

  • 의존성 설치 — yjs + y-prosemirror + @tiptap/extension-collaboration (3.23.1)
  • TiptapMarkdownEditor.collaborationDoc?: Y.Doc \| null prop 추가
  • prop 있으면 Collaboration extension 활성 + StarterKit undoRedo 비활성
  • prop null/undefined → 기존 단일 사용자 동작 (호환 보존)
  • sync server 는 후속 PR — 본 PR 은 prop reservation 만

v1.21~v1.24 검증

  • 누적 310+ 테스트
  • apps/web: 547 files / 7163 tests
  • functions: 27 files / 342 tests
  • 0 회귀, 0 type/lint 에러

v1.21~v1.24 후속 (Phase 8+)

  • v1.22 후속: cron 활성화 (사용자 contractualDeadline 입력 사건 N건 + owner 이메일 수신 의사)
  • v1.24 후속: y-firestore 또는 y-websocket sync server — Y.Doc update 영속화·실시간 sync
  • v1.24 후속: presence indicator + yjs 통합 (이름·커서 색상 sync — @tiptap/extension-collaboration-cursor)
  • v1.20 후속 (계속): dashboardWidgetClicks 측정 인프라 + 30일 후 widget deprecate 결정 PR

v1.11 Amendment (2026-05-10 PR #1947) — Phase 7b-full 진정한 완성

v1.8 (#1944) 알려진 한계 해소 — cron 이메일 URL 자체를 /login?redirect_to= 형식으로 변경 + (auth) layout 인증 redirect 분기 약화 → page level 위임.

v1.11 Decision

closed-loop 흐름:

  • cron 이메일 → /login?redirect_to=/legacy/{id}?renew=1&from=email-d{N}
  • 미인증: /login LoginForm → 로그인 → safeRedirectTo 검증 → /legacy/{id}?renew=1
  • 인증: /login server component → getRawSession 인증 검사 → resolveAuthenticatedRedirect(redirect_to) → 그 URL 로 즉시 redirect ✅

v1.11 신규

  • functions/src/email-deep-link.ts:
    • buildLoginRedirectUrl(legacyDocId, baseUrl?, source?)/login?redirect_to=/legacy/{id}?renew=1[&from=...]
    • buildInnerRenewalPath 분리 — inner deep-link path 단독 빌드
  • functions/src/send-office-expiry-reminders.ts:
    • cron 이 buildLoginRedirectUrl 사용 (기존 buildRenewalDeepLink 는 보존)
  • apps/web/app/(auth)/layout.tsx:
    • 인증 + tenantId 사용자 redirect 분기 약화 — page level 위임
    • 신규 사용자 (tenantId 부재) 만 /onboarding 강제
  • apps/web/app/(auth)/login/page.tsx:
    • server component 로 변경 (async)
    • searchParams 받아 getRawSession + resolveAuthenticatedRedirect 처리
  • apps/web/app/(auth)/signup/page.tsx + forgot-password/page.tsx:
    • 일관 패턴 — 인증 사용자 redirect 자체 처리

v1.11 보안 정합

  • server-side resolveAuthenticatedRedirect (safeRedirectTo) 재검증 — open redirect 차단
  • buildLoginRedirectUrl 의 inner path encodeURIComponent — path 주입 차단
  • safeRedirectTo 의 화이트리스트 (/legacy/, /dashboard, ...) 가 cross-app redirect 차단

v1.11 회귀 가드

  • cron-login-redirect-v1.11-invariants.test.ts (8 검사):
    • cron 이 buildLoginRedirectUrl 사용
    • layout 의 인증 redirect 약화 (redirect("/dashboard") 제거)
    • LoginPage / SignupPage / ForgotPasswordPage 자체 redirect 처리
    • 보안: server-side 재검증 + encodeURIComponent
  • functions buildLoginRedirectUrl.test.ts +4 — 형태/encoding/source/trailing slash

v1.11 누적 검증

  • 누적 200+ 테스트 (v1.0 51+ + v1.1 28 + v1.2 28 + v1.3 6 + v1.4 5 + v1.5 7 + v1.6 10 + v1.7 22 + v1.8 25 + v1.9 6 + v1.10 11 + v1.11 12)
  • e2e 누적 8 시나리오 (entry 3 + mock 4 + ai-mock 1)
  • apps/web: 537 files / 7080 tests
  • functions: 26 files / 333 tests

한 줄 본질 (사용자 강조선) — closed-loop 진정한 완성

"법률 사무소가 자기 데이터로 자기 AI 학습·활용해 소송을 더 빠르고 정확하게 관리"

  • 미인증/인증 양 흐름 모두 — 이메일 클릭 → /login redirect 처리 → 갱신 검토 Modal 자동 → 검토 노트 (Tenant + Platform RAG 결합) → 갱신 초안 markdown (Tiptap 편집) → finalize → legacyDocuments 누적 → chip + Panel + Dashboard stat 시각화 → 다음 갱신 RAG input
  • ADR 0015 — 사무실 기억 OCR + 임베딩 RAG (legacyDocuments 본 컬렉션)
  • ADR 0040 — ops 재정의 (만료 알림 통계도 ops 가시성에 추가될 후속 후보)
  • ADR 0041 — 두 RAG 자동 결합 (v1.1 Phase 4 갱신 검토 노트 동일 패턴)
  • PR #1904~ — category enum 분리 (litigation/office/advisory)
  • PR #1917~#1923 — v1.0 구현 7 PR (schema → UI → widget → cron → invariant → ADR → ops fixture)
  • PR #1924~#1928 — v1.1 Phase 4 갱신 검토 노트 5 PR (helper → action → modal → invariant+ADR → deep-link)
  • PR #1929~#1932 — v1.2 Phase 5 lite 갱신 초안 markdown 4 PR (helper → action → modal → invariant+ADR)
  • PR #1933~#1935 — v1.3 이메일 deep-link CTA + e2e 3 시나리오 + invariant 3 PR
  • PR #1936~#1939 — v1.4 click-through stat + .md 다운로드 + lite 안내 + 다중 e2e 4 PR
  • PR #1940 — v1.5 Phase 5c finalize → legacyDocuments 누적 (closed-loop 종착점)
  • PR #1941~#1942 — v1.6 Phase 5e+5f 갱신 chain 시각화 (chip + Panel + Dashboard stat)
  • PR #1943 — v1.7 Phase 5d lite markdown preview 토글
  • PR #1944 — v1.8 Phase 7b-full redirect_to 인프라 (safe-redirect helper + LoginForm)
  • PR #1945 — v1.9 e2e mock 풀 flow (page.route AI 가로채기)
  • PR #1946 — v1.10 Phase 5d-full Tiptap markdown editor (StarterKit + prosemirror-markdown)
  • PR #1947 — v1.11 Phase 7b-full 완성 (cron URL + layout 약화 + page level redirect_to)
  • PR #1948 — closed-loop master invariant (24 PR chain 종합 검증)
  • PR #1949 — v1.12 e2e mock dev hook (NEXT_PUBLIC_E2E_MOCK_AI 다단 가드)
  • PR #1950 — v1.13 Tiptap Link/Table/TaskList extension (Phase 5d-full+)
  • PR #1951 — v1.14 갱신 본문 revision history (Firestore subcollection)
  • PR #1952 — v1.15 Pack 4/5 만료 통합 grouping (UpcomingDeadlinesGroup wrapper)
  • PR #1953 — v1.16 saveRenewalRevisionAction UI 통합 (수동 snapshot 버튼)
  • PR #1954 — v1.17 통합 unified widget (DashboardUnifiedDeadlinesWidget)
  • PR #1955 — v1.18 Pack 4/5 contractualDeadline 필드 + 통합 widget 확장
  • PR #1956 — v1.19 협업 모드 lite (UI 진입점 reservation)
  • PR #1957 — v1.20 분리 widget 보존 결정 + 측정 인프라 ADR (data-driven deprecate plan)
  • PR #1958 — v1.21 contractualDeadline UI 입력 (사건 상세 편집)
  • PR #1959 — v1.22 sendContractualDeadlineReminders cron (D-30/7/1/0 분기)
  • PR #1960 — v1.23 presence indicator (Firestore subcollection 활성 사용자 추적)
  • PR #1961 — v1.24 yjs Collaboration prop reservation (sync server 후속)