ADR 0043 — office 카테고리 만료 자동화
Context
PR #1904~ 에서 legacyDocuments.category 가 3종 (litigation / office / advisory) 으로 분리됐다. office = 사무소 운영 자료 (근로계약·임대차·보험·취업규칙·세무·회계). 이 묶음은 본질적으로 만료일이 있는 문서가 다수다.
사용자 강조선 (2026-05-09): "업무 자동화 + 문서 자동화" — 한 줄 본질의 절반은 "사무소 업무를 더 빠르고 정확하게". litigation 카테고리는 기일·시효 알림이 이미 도메인 레벨로 구현됐지만 (sendHearingReminders · DashboardExpiringStatute · DashboardChildSupportArrears), office 는 만료 추적이 0 이었다.
요구사항:
- office 문서 업로드 시 만료일 입력 (선택)
- 변호사가 dashboard 첫 화면에서 "이번주 갱신해야 할 계약서" 즉시 식별
- D-30 / D-7 / D-1 / D-0 분기에 자동 알림 (이메일 + 인앱) — 알림 폭주 차단
- 의도적으로 알림 OFF 한 문서는 존중
Decision
데이터 모델
legacyDocuments.expiryDate?: string (YYYY-MM-DD) + expiryDocReminderEnabled?: boolean 두 필드 추가.
category === "office"일 때만 의미를 가짐 (UI/스키마 강제 분기)expiryDate미입력 시 알림·chip·widget 모두 silentexpiryReminderEnableddefault =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): roseimminent(D-7 이내): orangesoon(D-30 이내): amberapproaching(D-90 이내): mutedfar(90+): chip 노이즈 차단 (action 가능 시점만 가시화)
활성화 단계
Cron 은 production 에서 비활성 (사용자 0 동안 비용 0). 활성 조건:
- office 카테고리 문서 N건 존재 (tenant DB)
- owner 이메일 수신 의사 확인
- memory 512MiB 헤드룸 (production sweep 2026-05-04 정합)
활성화 절차: index.ts 의 // export { sendOfficeDocExpiryReminders } 주석 해제 → pnpm --filter functions build → firebase 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 testfunctions/src/expiry-reminder-pure.ts— Cloud Functions 측 복제 (별도 npm workspace)functions/src/send-office-expiry-reminders.ts— cronapps/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 정적 grepfunctions/__tests__/send-office-expiry-reminders-invariants.test.ts— cron 8 invariantfunctions/__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 builderapps/web/app/(workspace)/legacy/_lib/__tests__/contract-renewal-review.test.ts— 21 unit testapps/web/app/(workspace)/legacy/_actions/contract-renewal-review-action.ts— Server Action (RAG 결합)apps/web/lib/ai/client.tsgenerateContractRenewalReview— Gemini wrapper (App Check)apps/web/app/(workspace)/legacy/_components/ContractRenewalReviewModal.tsx— UI Modalapps/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 + defaultRenewedExpiryDateapps/web/.../legacy/_lib/__tests__/contract-renewal-draft.test.ts— 21 unit testapps/web/.../legacy/_actions/contract-renewal-draft-action.ts— Server Action (review 강제 검증)apps/web/lib/ai/client.tsgenerateContractRenewalDraft— 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.3 Amendment (2026-05-10 PR #1933~#1935) — 이메일 deep-link + e2e closed-loop
본문 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.ts는getFirestore()가 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 시나리오):
- dashboard 위젯 빈 상태 (office 0건)
- 시드 office 문서 → widget item ?renew=1 → Modal 자동 열림 → CTA 노출
- 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.tsbuildExpiryReminderHtml— CTA 버튼 결합functions/__tests__/build-renewal-deep-link.test.ts— 6 unit teste2e/regression-office-renewal-flow.spec.ts— 3 시나리오e2e/helpers/firestore.tsseedOfficeExpiryLegacyDoc— 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 tracking— v1.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 Actionapps/web/types/legacy-document.ts—parentLegacyDocId?: 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 hideLegacyDocCard—parentLegacyDocId있으면 "↪ 갱신본" chip + tooltipLegacyDocDetail— RenewalChainPanel 마운트
- #1942 Phase 5f (Chronicle stat):
getRenewalChainStatAction—sourceType=authored+ parentLegacyDocId 카운트 (.selectPII 안전)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.tspure helper — 외부 라이브러리 의존 0- 보안: HTML escape 우선 → escape 된 본문 위에 한정 패턴만 markdown → HTML 변환
- 지원: 헤더 (h1/h2/h3) · 굵게 · 기울임 · 인라인 코드 · 코드 블록 · 리스트 · 인용 · 가로줄
- 미지원 (의도): 링크 (URL 보안 검증 비용), 이미지 (외부 노출 위험), HTML inline (직접 작성 차단)
ContractRenewalReviewModal—viewModetri-state (raw/edit/preview) +ViewModeTabsub-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, 실패 시/dashboardfallbackbuildLoginRedirectUrl(base, path)—/login?redirect_to=...URL 빌드 (외부 deep-link 용)
decideAuthRedirect(decoded, redirectTo?)— redirect_to 인자 추가, tenantId 부재 시 onboarding 우선createSessionAction(idToken, rawRedirectTo?)— server-sidesafeRedirectTo재검증 강제LoginForm—useSearchParams("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) 패턴 또는getAiProviderDI
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 동기화
ContractRenewalReviewModalviewMode="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.tsgenerateContractRenewalReview/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 자동 저장RenewalRevisionsPanelUI — sourceType=authored 분기 + viewer modal (read-only + Copy)
v1.15 (#1952) Pack 4/5 만료 통합 grouping
UpcomingDeadlinesGroupwrapper — 기존 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}/presencesubcollection — 활성 사용자 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 입력
updateCaseContractualDeadlineActionServer Action — Zod + ownership gate + 화이트리스트 + activity logContractualDeadlineSectionUI — 표시/편집/삭제 + Handshake emeraldCaseData.contractualDeadline?+ reminderEnabled? 타입 확장CaseDetailClient마운트 (CaseSummaryCards 직후)
v1.22 (#1959) sendContractualDeadlineReminders cron
- 비활성 cron —
0 9 * * *Asia/Seoul + 512MiB - collectionGroup cases +
where contractualDeadline == date4 분기 (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
usePresencehook — Firestore subcollection 활성 사용자 추적- heartbeat 25초 + stale 60초 cutoff + onSnapshot real-time
- mount setDoc / interval updateDoc / unmount deleteDoc cleanup
PresenceIndicatorUI — 혼자/다중 분기 + avatar overflow + self ringLegacyDocDetail마운트
v1.24 (#1961) yjs Collaboration prop reservation
- 의존성 설치 — yjs + y-prosemirror + @tiptap/extension-collaboration (3.23.1)
TiptapMarkdownEditor.collaborationDoc?: Y.Doc \| nullprop 추가- 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} - 미인증:
/loginLoginForm → 로그인 →safeRedirectTo검증 →/legacy/{id}?renew=1✅ - 인증:
/loginserver 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는 보존)
- cron 이
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 pathencodeURIComponent— 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
- cron 이
- 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
Related
- 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 후속)