FeaturesSettings (설정)약관 게시 (/settings/policies)

약관 게시 — /settings/policies

SPEC #023 정합 · 버전 이력·예약 게시·마크다운은 SPEC #033·#038 확장. 시각 시안 교체 완료 — 베이스 ops-settings(#031-C), 추가분 ops-policies-history.

Overview

운영사(OPERATOR)가 이용약관·개인정보처리방침을 백오피스 UI 에서 게시하는 화면. /settings/policies 에서 현재 유효본 표시 + 새 버전 게시 + (#033) 버전 이력 조회·예약 게시· 마크다운 작성/미리보기를 한다. SPEC #128 이후 이 화면은 설정 7서브 탭 셸(18-7 약관 탭)으로 편입되어 (protected)/settings/layout.tsx<SettingsTabs/> 아래에 렌더된다(화면·다이얼로그 동작 불변). 사이드바 “설정” 항목(/settings → 첫 탭 redirect)에서 약관 탭으로 진입한다. 영역 개요는 설정 7서브 참고.

시안 출처: handoff 시안 design/screens/ops-settings.jsx(§PolicySection · §PublishDialog — 활성본 카드 + 게시 2-step 베이스) + design/screens/ops-policies-history.jsx (§OpsPoliciesHistory 이력 테이블·MarkdownView 상세 · §PublishWithScheduleDialog 작성/미리보기 탭·발효 시각 즉시/예약 · §CancelScheduleDialog 예약 취소 2-step). 기존 @linkmusic/ui atom + admin 관례(hq-detail #031-B)에 매핑 — status 배지(예약 info·현재 유효 success·과거 muted)·예약 게시 step2 info 톤·MarkdownView 타이포(h1 22/h2 18/h3 15px·링크 primary) 정합. 새 색/아이콘 추측 없음([[feedback_design_only_from_handoff]]).

Spec

라우트·데이터 (server component)

  • (protected)/settings/policies/page.tsx — 보호 라우트 ((protected) layout me 가드 + page 의 refresh-aware 세션 체크). server component 가 활성본을 병렬 fetch:
    • backendActiveTermsGET /api/v1/terms/active
    • backendActivePrivacyGET /api/v1/privacy-policy/active
  • 활성 조회는 backend 에서 public(인증 불요)이라 토큰 없이 호출한다. settings 라우트 자체의 보호는 layout me 가드가 담당한다.
  • 결과는 문서별 PolicyState 로 client 에 prop 전달.

미게시 처리 (404 ≠ 에러)

활성 조회 endpoint(#033 의미변경 — “현재 유효본” = effective_at <= now 중 최신)는 현재 유효본 없음 시 404 를 던진다(TERMS_NOT_PUBLISHED·PRIVACY_POLICY_NOT_PUBLISHED). 이는 에러가 아니라 정상적인 “미게시” 상태다.

상태분기UI
유효본 존재kind: "ok"현재 유효본 — 활성본 DescCard(버전 success pill · 게시일 KST) + 내용 미리보기(<MarkdownView>) 2컬럼
404 미게시kind: "not-published"점선 박스 빈 상태 (“아직 게시된 …이 없습니다”) + [첫 버전 게시]
5xx / 네트워크kind: "error"섹션 내 danger Banner (다른 섹션과 격리)

한 문서 fetch 실패가 다른 문서 섹션 표시를 막지 않는다.

페이지 구성 (2섹션)

  • <PageHeader> 제목 “설정 · 약관 / 개인정보” · 부제 “현재 유효본은 각 1개(발효 시각 기준) · 즉시/예약 게시 · 이전 버전 영구 보관”.
  • 본문은 max-w-[880px] 중앙 정렬. 섹션 2개: 이용약관(FileText 아이콘 칩) · 개인정보처리방침(ShieldCheck 아이콘 칩). 각 섹션:
    • 헤더: 아이콘 칩(--primary-soft/--primary) + 타이틀 + 활성 요약 + [이력 보기] ghost 버튼(History 아이콘) + [새 버전 게시] primary 버튼.
    • 활성본 (ok): 좌측 활성본 DescCard(버전 StatusPill tone="success" dot · 게시일 formatKstDateTime, Asia/Seoul 고정) + 우측 내용 미리보기(<MarkdownView> 렌더, max-h-[180px] 스크롤). 본문이 markdown 이므로 plain whitespace-pre-wrap → 마크다운 렌더로 교체(#033).
    • 미게시 (not-published): 점선 박스 빈상태 + [첫 버전 게시].
  • 게시일은 formatKstDateTime(@/lib/format, Asia/Seoul 고정) — 로컬 TZ 의존 미사용.

마크다운 렌더 (components/markdown-view.tsx, #033)

본문은 BE 에 markdown plain string 으로 저장(BE 무변경)되고 FE 가 렌더한다.

  • react-markdown + remark-gfm(표·리스트·링크·취소선) + rehype-sanitize(XSS allowlist).
  • sanitize 는 GitHub 기본 스키마(defaultSchema) 기반 — script·iframe·on* 이벤트 핸들러· 위험 URL scheme(javascript: 등)을 제거하고, 제목/문단/리스트/표/링크/코드/blockquote 만 허용. 운영자(신뢰 경계 내부) 입력이라도 저장 데이터가 그대로 렌더되므로 sanitize 를 필수 적용.
  • 활성본 미리보기·이력 상세·게시 다이얼로그 미리보기 탭에서 공용 사용.

버전 이력 다이얼로그 (policy-history-dialog.tsx, #033)

각 섹션 [이력 보기] → 약관/개인정보를 kind prop 으로 구분해 재사용하는 다이얼로그.

  • 목록: useListTermsHistory/useListPrivacyPolicyHistory (예약본 포함, effectiveAt DESC, 본문 제외). 각 행 = version · 발효 시각(KST) · 게시 시각(KST) + status 배지(StatusPill: SCHEDULED=info · ACTIVE=success · SUPERSEDED=muted).
  • 상세: 행 클릭 → useGetTermsVersion/useGetPrivacyPolicyVersion(by-id lazyenabled 가 열림+선택 시에만 fetch) → 본문을 <MarkdownView> 로 렌더. [목록]으로 복귀.
  • 예약 취소 (SPEC #038): 상세 view 가 status === SCHEDULED 일 때만 [예약 취소] danger 버튼 노출 (ACTIVE·SUPERSEDED 에는 미노출 — 영구 보존 불변식). 클릭 → 2-step 확인(#029 파괴적 액션 관용구: “이 예약본을 취소하면 발효되지 않습니다” danger Banner + danger 확인 버튼). 확인 → kind 에 따라 useCancelTermsSchedule/useCancelPrivacyPolicySchedule(pathParam id, DELETE 204) 호출 → 성공 시 history query invalidate + 상세 닫고 목록 view 복귀. 실패는 인라인 danger Banner 로 매핑:
    • 409 LEGAL_DOC_NOT_SCHEDULED → “이미 발효되었거나 대체되어 취소할 수 없습니다.” (목록 stale race)
    • 404 LEGAL_DOC_NOT_FOUND → “이미 삭제된 예약본입니다.” (다른 운영자가 먼저 취소)
    • 그 외 권한/일시 장애 fallback.
  • 닫힌 상태에서는 다이얼로그를 마운트하지 않아 query 가 실행되지 않는다.

게시 다이얼로그 (policy-publish-dialog.tsx)

2-step (시안 ops-settings §PublishDialog) — 제목·부제가 단계별로 전환된다.

  • 입력 단계: Field(label + hint) 위에 version mono input(max 32) · 발효 시각 datetime-local input · content 작성/미리보기 탭(Tabs/Tab, role=tab/tabpanel· aria-selected) — 작성=textarea(max 200000), 미리보기=<MarkdownView> 렌더(#033).
  • 발효 시각(effectiveAt) 입력 (#033):
    • datetime-local 은 KST 벽시계 문자열(YYYY-MM-DDTHH:mm)을 받고, 제출 시 kstLocalInputToUtcIso (KST=UTC+9 고정 오프셋)로 UTC ISO 로 변환해 보낸다. 직접 toLocaleString·new Date(local) (머신 TZ 의존) 미사용 — SSR/CSR·머신 TZ 무관 결정적.
    • 빈 값이면 effectiveAt 미전송 = 즉시 게시. 미래 시각이면 예약 게시. 안내: “비우면 즉시 게시, 미래 시각이면 예약”.
  • 검증 (zod, frontend.md #8 — backend LegalDocumentPublishRequest 미러):
    • version: maxLength 32max(32) + trim min(1)(NotBlank 미러).
    • content: trim min(1) + max(200000).
    • effectiveAtLocal: 빈 값 허용(즉시) 또는 현재 시각 이후(과거 예약 거부). backend 가 과거 (>60s)를 400 LEGAL_DOC_INVALID_EFFECTIVE_AT 로 최종 거부하므로 클라는 보수적 사전 검증.
    • 제출 payload 는 trim 된 값(공백-only 거부). 미통과 시 [다음] 비활성.
  • 확인 단계 (필수): [다음] → danger Banner(되돌릴 수 없음 경고) + 게시 요약 dl(대상·새 버전·발효 시각[KST, 즉시면 “즉시 발효”]) + 명시적 게시 실행 확인. 제목·부제는 즉시/예약에 따라 전환(“즉시 발효됩니다” / “지정한 시각에 발효됩니다”). [뒤로] 로 복귀.
  • mutation: terms 는 usePublishTerms, privacy 는 usePublishPrivacyPolicy(SPEC #028 operationId 유니크화 후 정상화 — 이전 usePublish/usePublish1 대체). mutator apiFetch 가 BFF catch-all 경유 — 토큰 서버 전용.
    • terms → /api/backend/api/v1/admin/terms
    • privacy → /api/backend/api/v1/admin/privacy-policy
  • 성공 시 router.refresh() (server component 가 활성본 재fetch) + 다이얼로그 닫기.
  • pending 중 닫기·중복 제출 차단.

에러 매핑 (extractCode)

code / status메시지
409 LEGAL_DOC_DUPLICATE_VERSION”이미 존재하는 버전입니다. 다른 버전 번호를 입력해주세요.”
400 LEGAL_DOC_INVALID_EFFECTIVE_AT”발효 시각은 현재 이후여야 합니다.” (#033 — 과거 예약 거부)
404 LEGAL_DOC_NOT_FOUND”문서를 찾을 수 없습니다.” (#033 — by-id 상세 미존재)
409 LEGAL_DOC_ACTIVATION_CONFLICT”다른 게시 작업과 충돌했습니다. 잠시 후 다시 시도해주세요.” (동시 게시 race)
403 / FORBIDDEN”이 작업을 수행할 권한이 없습니다.”
5xx”서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.”
네트워크”서버에 연결할 수 없습니다.”

실패 시 입력 단계로 되돌려 값 수정 가능.

BE 계약 (#033 확장)

  • 게시 POST /api/v1/admin/terms·/privacy-policy (OPERATOR) — LegalDocumentPublishRequest{ version, content, effectiveAt? }LegalDocumentResponse{ version, content, publishedAt, effectiveAt, status }. effectiveAt 미지정·현재=즉시(ACTIVE), 미래=예약(SCHEDULED), 과거=400.
  • 활성 조회 GET /api/v1/terms/active·/privacy-policy/active (public) → 현재 유효본 LegalDocumentResponse(effective_at <= now 중 최신, statusACTIVE 보정).
  • 이력 GET /api/v1/admin/{terms,privacy-policy} (OPERATOR) → LegalDocumentHistoryResponse{ items[] }(본문 제외).
  • 단건 GET /api/v1/admin/{terms,privacy-policy}/{id} (OPERATOR) → LegalDocumentResponse(본문 포함) · 404 LEGAL_DOC_NOT_FOUND.
  • 예약 취소 DELETE /api/v1/admin/{terms,privacy-policy}/{id} (OPERATOR, #038) → 204 No Content. 훅: useCancelTermsSchedule·useCancelPrivacyPolicySchedule(pathParam id). SCHEDULED·미발효 (effective_at > now)일 때만 hard-delete · 409 LEGAL_DOC_NOT_SCHEDULED · 404 LEGAL_DOC_NOT_FOUND.

Not in scope (후속)

  • 공개(온보딩) 약관 화면 마크다운 렌더 — admin 한정.
  • 재동의 흐름(버전 변경 시 재동의 배너).
  • 예약본 수정 UI — 취소 후 재게시로 대체(취소는 #038 에서 지원).
  • 본문 버전 diff 비교 뷰.

References

  • SPEC #023 · SPEC #033 · SPEC #038 (예약 취소)
  • apps/admin/src/app/(protected)/settings/policies/page.tsx · policies-client.tsx · policy-publish-dialog.tsx · policy-history-dialog.tsx
  • apps/admin/src/components/markdown-view.tsx
  • apps/admin/src/lib/format.ts (kstLocalInputToUtcIso·utcIsoToKstLocalInput·formatKstDateTime)
  • apps/admin/src/lib/backend.ts (BackendLegalDocumentResponse·BackendLegalDocumentHistoryItem· BackendLegalDocumentHistoryResponse·BackendLegalDocumentResponseStatus·BackendLegalDocumentPublishRequest)
  • 사이드바 진입점: features/design-system/shells “설정” 항목