@roottale/cms-mcp 0.72.1 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +47 -0
- package/docs/blog.md +7 -0
- package/docs/content-models-and-exposures.md +13 -0
- package/docs/inquiries.md +12 -2
- package/docs/revalidation-webhooks.md +1 -0
- package/docs/seo.md +86 -2
- package/docs/theme-and-settings.md +92 -10
- package/docs/visual-content-editing.md +45 -0
- package/examples/nextjs/app/.well-known/roottale.json/route.ts +16 -1
- package/examples/nextjs/app/api/revalidate/route.ts +2 -2
- package/examples/nextjs/app/error.tsx +21 -0
- package/examples/nextjs/app/layout.tsx +18 -0
- package/examples/nextjs/app/page.tsx +11 -1
- package/examples/nextjs/components/shared-field-output.tsx +9 -0
- package/examples/nextjs/components/site-inquiry-form.tsx +21 -1
- package/examples/nextjs/external-source-sync.md +53 -0
- package/examples/nextjs/hospital-fees/README.md +72 -0
- package/examples/nextjs/hospital-fees/content-area.json +43 -0
- package/examples/nextjs/hospital-fees/files/app/guide/non-covered/page.tsx +16 -0
- package/examples/nextjs/hospital-fees/files/app/preview/post/[id]/page.tsx +34 -0
- package/examples/nextjs/hospital-fees/files/cms/non-covered-fees-page.json +12 -0
- package/examples/nextjs/hospital-fees/files/components/site/SiteAnalytics.tsx +16 -0
- package/examples/nextjs/hospital-fees/files/features/site/non-covered/NonCoveredFees.tsx +32 -0
- package/examples/nextjs/hospital-fees/files/features/site/non-covered/nonCoveredFees.module.css +11 -0
- package/examples/nextjs/hospital-fees/files/lib/root-admin/content-table.ts +51 -0
- package/examples/nextjs/hospital-fees/files/lib/root-admin/miridoc-client.ts +10 -0
- package/examples/nextjs/hospital-fees/files/lib/root-admin/miridoc-fees.ts +89 -0
- package/examples/nextjs/hospital-fees/files/lib/root-admin/non-covered-fees.ts +37 -0
- package/examples/nextjs/hospital-fees/files/tests/non-covered.test.tsx +69 -0
- package/examples/nextjs/hospital-fees/files.json +13 -0
- package/examples/nextjs/lib/page-metadata.ts +4 -0
- package/examples/nextjs/lib/route-metadata.ts +14 -5
- package/examples/nextjs/lib/sync-external-inquiries.ts +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useTransition, type FormEvent, type ReactNode } from "react";
|
|
4
4
|
import { readAttribution } from "@roottale/cms-client/attribution";
|
|
5
|
+
import { readJourney, trackCustomEvent, trackFormError } from "@roottale/analytics-runtime";
|
|
5
6
|
import type { InquiryAnswer, InquiryFormField, PublishedInquiryForm, SubmitFormInquiryResult } from "@roottale/cms-client/server";
|
|
6
7
|
|
|
7
8
|
import { submitSiteForm, type SiteFormAttempt } from "../lib/actions/submit-site-form";
|
|
@@ -19,6 +20,7 @@ export function SiteInquiryForm({
|
|
|
19
20
|
|
|
20
21
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
21
22
|
event.preventDefault();
|
|
23
|
+
trackCustomEvent("form_submit_attempt", { form_id: formKey });
|
|
22
24
|
const data = new FormData(event.currentTarget);
|
|
23
25
|
// 결과가 불명확하면 answers/version/동의/attribution과 key 모두 유지한다.
|
|
24
26
|
// 보안 토큰만 새 위젯 결과로 교체할 수 있다.
|
|
@@ -29,6 +31,8 @@ export function SiteInquiryForm({
|
|
|
29
31
|
privacyConsent: data.get("privacyConsent") === "on",
|
|
30
32
|
idempotencyKey: crypto.randomUUID(),
|
|
31
33
|
attribution: readAttribution(),
|
|
34
|
+
// 최초 제출 시 스냅샷을 고정한다. 재시도에서는 같은 제출 내용을 유지한다.
|
|
35
|
+
journey: readJourney(),
|
|
32
36
|
}),
|
|
33
37
|
turnstileToken: text(data.get("cf-turnstile-response")) || undefined,
|
|
34
38
|
};
|
|
@@ -37,12 +41,28 @@ export function SiteInquiryForm({
|
|
|
37
41
|
try {
|
|
38
42
|
const response = await submitSiteForm(formKey, nextAttempt);
|
|
39
43
|
setResult(response);
|
|
44
|
+
if (response.ok) {
|
|
45
|
+
// 실제 접수 성공 응답에서만 완료를 기록한다.
|
|
46
|
+
trackCustomEvent("lead_submit", { form_id: formKey });
|
|
47
|
+
} else {
|
|
48
|
+
const fields = Object.keys(response.fieldErrors);
|
|
49
|
+
if (fields.length > 0) {
|
|
50
|
+
for (const key of fields) trackFormError(formKey, "invalid", key);
|
|
51
|
+
} else {
|
|
52
|
+
const code = response.kind === "transport" ? "network"
|
|
53
|
+
: response.status === 429 ? "rate_limited"
|
|
54
|
+
: response.status === 401 || response.status === 403 ? "verification"
|
|
55
|
+
: response.status !== null && response.status >= 500 ? "server" : "unknown";
|
|
56
|
+
trackFormError(formKey, code);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
40
59
|
// 저장되지 않았음이 명확한 입력/인증 오류만 편집을 다시 허용한다.
|
|
41
60
|
// 409 충돌과 5xx, 응답 유실은 같은 제출을 보존한다.
|
|
42
61
|
// 앞선 응답이 불확실했다면 뒤의 인증/검증 실패로 기존 제출을 지우지 않는다.
|
|
43
62
|
if (attempt === null && !response.ok && response.kind === "api" && response.status !== null
|
|
44
63
|
&& [400, 401, 403, 404, 422, 429].includes(response.status)) setAttempt(null);
|
|
45
64
|
} catch {
|
|
65
|
+
trackFormError(formKey, "network");
|
|
46
66
|
// 사이트 Server Action의 응답 자체가 끊겨도 기존 key를 유지한다.
|
|
47
67
|
setResult({
|
|
48
68
|
ok: false, kind: "transport", code: "network_error", status: null,
|
|
@@ -62,7 +82,7 @@ export function SiteInquiryForm({
|
|
|
62
82
|
);
|
|
63
83
|
|
|
64
84
|
return (
|
|
65
|
-
<form onSubmit={onSubmit}>
|
|
85
|
+
<form onSubmit={onSubmit} data-track-form={formKey}>
|
|
66
86
|
<h2>{form.definition.title}</h2>
|
|
67
87
|
<fieldset disabled={pending || attempt !== null}>
|
|
68
88
|
<legend>문의 내용</legend>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# 외부 원장 원고 연결 예제
|
|
2
|
+
|
|
3
|
+
동기화 작업은 서버의 관리 API 키로 실행한다. 공개 사이트나 브라우저에 쓰기 키를
|
|
4
|
+
전달하지 않는다. 아래 예제의 ID·key는 해당 사이트의 기존 값으로 바꾼다.
|
|
5
|
+
|
|
6
|
+
`POST https://api.roottale.com/v1/cms/posts`
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"site_id": "site-id",
|
|
11
|
+
"model_key": "article",
|
|
12
|
+
"author_profile_id": "site-author-id",
|
|
13
|
+
"source_key": "notion:stable-page-id",
|
|
14
|
+
"title": "검수한 제목",
|
|
15
|
+
"slug": "stable-article-slug",
|
|
16
|
+
"body_json": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "검수한 본문" }] }] },
|
|
17
|
+
"category_ids": ["existing-category-id"],
|
|
18
|
+
"publish": false
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
201은 생성, 200은 같은 키로 먼저 생성된 글이다. 후자의 본문을 생성 요청으로
|
|
23
|
+
덮어쓰지 않는다. 응답을 잃어버렸을 때도 같은 키로 재요청한다.
|
|
24
|
+
|
|
25
|
+
기존 글을 수정할 때는 `PATCH /v1/cms/posts/{id}`에 조회한 버전을 함께 보낸다.
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"site_id": "site-id",
|
|
30
|
+
"expected_updated_at": "2026-10-16T01:00:00.000Z",
|
|
31
|
+
"title": "검수한 수정 제목"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
409이면 사용자의 변경을 먼저 확인한다. SEO는 `GET/PATCH /v1/cms/metadata/post/{id}`와
|
|
36
|
+
`expected_version`을 사용하고, 발행은 `POST /v1/cms/posts/{id}/publish`에 최신
|
|
37
|
+
`expected_updated_at`을 보낸다. 공개 주소는 CMS의 `path`를 기준으로 계산한다.
|
|
38
|
+
응답을 받았다는 사실과 실제 사이트 반영 완료는 다르므로 등록된 revalidation도 확인한다.
|
|
39
|
+
|
|
40
|
+
원장에서 선택한 관련 글만 노출하려면 이 예제의 블로그 상세와 미리보기 페이지의
|
|
41
|
+
`RootTaleBlogPost` 옵션을 함께 바꾼다. 명시적 `related_posts`는 계속 표시되고
|
|
42
|
+
카테고리 기반 자동 추천만 꺼진다.
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
<RootTaleBlogPost
|
|
46
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
47
|
+
slugOrId={post.id}
|
|
48
|
+
relatedPostsCount={0}
|
|
49
|
+
/>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
전문: [관리 API](../../docs/api-reference.md), [SEO 계약](../../docs/seo.md),
|
|
53
|
+
[웹훅 연결](../../docs/revalidation-webhooks.md).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# 병원 공통 비급여 자동 연동
|
|
2
|
+
|
|
3
|
+
병원 홈페이지의 비급여는 미리닥 API를 기본 자료로 사용한다. ROOT-ADMIN의
|
|
4
|
+
`사이트 내용`에서 원본과 확인일을 보고, 병원 수정·추가·숨김만 별도로 저장한다.
|
|
5
|
+
원본 조회나 갱신은 CMS 문서를 쓰지 않는다. 병원마다 같은 기능을 제공하되 기관 ID로 구분한다.
|
|
6
|
+
`content-area.json`은 편집 계약, `files/`는 고객에게 납품하는 공개 소스다.
|
|
7
|
+
|
|
8
|
+
## 신규·기존 병원 적용
|
|
9
|
+
|
|
10
|
+
신규는 `pnpm starter:init <빈 경로> --industry medical --miridoc-id <6자리 기관 ID>`로
|
|
11
|
+
생성한다. 미리닥 `/h/<ID>`에서 기관명·주소·연락처를 확인한 뒤 ID를 연결한다.
|
|
12
|
+
공개 `/guide/non-covered`, 편집 slug `non-covered-fees`, 빈 보완 초안, 미리보기,
|
|
13
|
+
푸터 링크를 Materializer의 manifest·외부 receipt로 추적한다. 생성만으로 운영에 쓰지 않는다.
|
|
14
|
+
|
|
15
|
+
기존 병원은 기존 주소·디자인·문서를 유지하며 `content-area.json`을 계약의
|
|
16
|
+
`contentAreas`에 합친다. table의 `source.institutionId`를 설정하고 `lib/root-admin`의
|
|
17
|
+
공개 읽기와 `NonCoveredFees`의 검색·분류·HTML 표를 연결한다. 기존 preview 라우트에는
|
|
18
|
+
비급여 분기만 합친다. 고객 저장소는 비공개 플랫폼 패키지를 import하지 않는다.
|
|
19
|
+
|
|
20
|
+
공통 읽기 정본은 `packages/site-content-contract/src/miridoc-fees.ts`다.
|
|
21
|
+
`node scripts/sync-hospital-fees-runtime.mjs`로 이 모듈의 복사본을 동기화한다.
|
|
22
|
+
JavaScript 고객은 `--javascript <대상 파일>`, 일치 검사는 `--check`를 쓴다.
|
|
23
|
+
|
|
24
|
+
## 자동 자료와 병원 수정본
|
|
25
|
+
|
|
26
|
+
- 읽기 API: `GET https://miridoc.com/api/v1/institutions/<ID>/non-covered`.
|
|
27
|
+
인증정보 없이 공개 기관 스냅샷의 **전체 행**을 읽는다. 화면 더 보기 API를 쓰지 않는다.
|
|
28
|
+
- 현재 원천은 심평원의 병원급 비급여 신고 자료다. 의원급·누락 항목은 자동 자료가
|
|
29
|
+
없을 수 있다. `no_source`·`not_loaded`와 503 장애를 구분한다. 보완 금액을 만들지 않는다.
|
|
30
|
+
- 원천 행은 `md-…` ID, 자동 분류는 `mdg-…` ID다. 병원 추가 행은 UUIDv7이다.
|
|
31
|
+
원천의 금액·날짜 변경은 행 ID를 유지한다. 명칭·원천 순번 등 정체성이 달라지면
|
|
32
|
+
이전 수정본을 자동 승계하지 않는다. 원천에서 빠진 수정본은 관리자에서 보관·확인하고 공개에서 제외한다.
|
|
33
|
+
- 원본의 **수정**을 누르면 해당 행 전체의 병원 수정본을 만든다. 저장 후에는 그 행에
|
|
34
|
+
수정값을 사용한다. **수정본 삭제 · 자동값 복원**을 저장하면 원천을 다시 따른다.
|
|
35
|
+
원천 분류·순서는 그대로 유지한다. 병원 추가 행만 분류 이동·순서를 관리한다.
|
|
36
|
+
- 기존 문서는 병원 안내로 보존한다. 원천과 코드·이름이 같아도 임의로 덮지 않는다.
|
|
37
|
+
중복 항목의 정리는 병원이 원문과 새 자료를 대조한 뒤 한다.
|
|
38
|
+
- CMS 보완 문서가 없거나 비공개여도 자동 자료는 계속 표시한다. 보완 내용만 제외된다.
|
|
39
|
+
자동 항목의 숨김은 그 행의 수정본에서 관리하고 전체 자동 연결은 계약에서 해제한다.
|
|
40
|
+
|
|
41
|
+
## 저장·공개 경계
|
|
42
|
+
|
|
43
|
+
`kind: "table"` 값은 `[{ id, title, rows: [{ id, cells: string[], hidden?: boolean }] }]`
|
|
44
|
+
JSON 문자열이다. 금액·코드 앞자리 0·공란·범위·날짜를 보존한다. 병원 수정·추가는
|
|
45
|
+
100분류·1,000행, 셀당 2,000자, 문서 값 합계 500,000자까지다. 자동 원본의 한도는 별도다.
|
|
46
|
+
셀별 구 문서는 table의 `legacy`에 그룹·행·셀 키를 매핑한다. 첫 저장 때 변환하며,
|
|
47
|
+
기존 수정 이력도 같은 방식으로 읽는다. 누락·혼합·중복 입력은 부분 저장하지 않는다.
|
|
48
|
+
|
|
49
|
+
기존 저장·발행 권한, 동시 수정 검사, 수정 이력·복원, 서명 웹훅을 사용한다.
|
|
50
|
+
인증된 preview는 현재 초안과 원천을 합치며 CMS 공개본으로 초안을 덮지 않는다.
|
|
51
|
+
no-store·noindex·no-referrer 및 분석 제외를 유지한다. 공개 표의 숨김 행은 서버에서
|
|
52
|
+
제거하고 브라우저 데이터에도 보내지 않는다. 수정 행과 자동 출처·확인일은 구분한다.
|
|
53
|
+
장애는 예전 로컬 금액이나 정상 빈 표로 바꾸지 않는다.
|
|
54
|
+
|
|
55
|
+
## 배포 순서
|
|
56
|
+
|
|
57
|
+
운영 반영은 사용자 승인 후 실행한다. 미리닥 원천 행 ID를 담은 기관 스냅샷 재발행과
|
|
58
|
+
API 배포 → ROOT-ADMIN → 기존 계약·보완 문서 백업 및 계약 병합 → 병원 사이트 순서다.
|
|
59
|
+
API는 이전 ID 없는 스냅샷을 503으로 처리하므로 선행 배포 없이 사이트만 전환하지 않는다.
|
|
60
|
+
기존 초안·발행본은 임의로 덮거나 일괄 발행하지 않는다. 최초 보완 문서도 동일 slug가
|
|
61
|
+
없을 때만 `cms/non-covered-fees-page.json`으로 생성한다.
|
|
62
|
+
|
|
63
|
+
미리닥의 기존 수집 주기를 따르며 API 공유 캐시와 홈페이지 데이터 재검증은 각각 5분이다.
|
|
64
|
+
병원 수정은 사이트의 기존 서명 웹훅으로 반영한다. 출시 전에는 실제 기관 식별,
|
|
65
|
+
미리보기·저장·숨김·복원·원천 삭제·장애·390px 화면을 확인한다. 미리보기 도메인은 noindex를 유지한다.
|
|
66
|
+
|
|
67
|
+
## 테스트 실행 위치
|
|
68
|
+
|
|
69
|
+
MCP 서버의 검사는 `pnpm --filter @roottale/cms-mcp test`로 실행한다.
|
|
70
|
+
`files/tests/non-covered.test.tsx`는 고객에게 생성되는 Next.js 테스트이므로 생성된
|
|
71
|
+
병원 사이트에서 `pnpm exec vitest run tests/non-covered.test.tsx`로 실행한다.
|
|
72
|
+
생성된 사이트의 `pnpm test`와 CI에도 포함되며, MCP 서버에 React 의존성을 추가하지 않는다.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "non-covered-fees",
|
|
3
|
+
"pageSlug": "non-covered-fees",
|
|
4
|
+
"label": "비급여 진료비",
|
|
5
|
+
"path": "/guide/non-covered",
|
|
6
|
+
"editor": {
|
|
7
|
+
"kind": "fields",
|
|
8
|
+
"fields": [
|
|
9
|
+
{
|
|
10
|
+
"key": "fees",
|
|
11
|
+
"label": "비급여 항목",
|
|
12
|
+
"group": "비급여 진료비",
|
|
13
|
+
"kind": "table",
|
|
14
|
+
"table": {
|
|
15
|
+
"columns": [
|
|
16
|
+
"분류",
|
|
17
|
+
"명칭",
|
|
18
|
+
"코드",
|
|
19
|
+
"구분",
|
|
20
|
+
"비용",
|
|
21
|
+
"최저비용",
|
|
22
|
+
"최고비용",
|
|
23
|
+
"치료재료대 포함여부",
|
|
24
|
+
"약제비 포함여부",
|
|
25
|
+
"특이사항",
|
|
26
|
+
"최종변경일"
|
|
27
|
+
],
|
|
28
|
+
"nameColumn": 1,
|
|
29
|
+
"maxRows": 1000,
|
|
30
|
+
"source": {
|
|
31
|
+
"provider": "miridoc"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"key": "note",
|
|
37
|
+
"label": "비용 안내",
|
|
38
|
+
"group": "비급여 진료비",
|
|
39
|
+
"kind": "multiline"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getNonCoveredFees } from "../../../lib/root-admin/non-covered-fees";
|
|
2
|
+
import { NonCoveredFees } from "../../../features/site/non-covered/NonCoveredFees";
|
|
3
|
+
import { getRouteMetadata } from "../../../lib/root-admin/metadata";
|
|
4
|
+
import { connection } from "next/server";
|
|
5
|
+
|
|
6
|
+
export const revalidate = 60;
|
|
7
|
+
export const generateMetadata = () => getRouteMetadata("non-covered");
|
|
8
|
+
|
|
9
|
+
export default async function NonCoveredPage() {
|
|
10
|
+
await connection();
|
|
11
|
+
const schedule = await getNonCoveredFees();
|
|
12
|
+
return <main id="main-content" className="reading-page">
|
|
13
|
+
<header className="page-heading"><h1>비급여 진료비 안내</h1><p>항목명이나 코드로 비용을 확인하세요. 금액의 단위는 원입니다.</p></header>
|
|
14
|
+
<NonCoveredFees {...schedule} />
|
|
15
|
+
</main>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { fetchPostPreview } from "@roottale/cms-client/server";
|
|
4
|
+
import { getRootAdminConnection } from "../../../../lib/root-admin/config";
|
|
5
|
+
import { feeScheduleFromPost } from "../../../../lib/root-admin/non-covered-fees";
|
|
6
|
+
import { NonCoveredFees } from "../../../../features/site/non-covered/NonCoveredFees";
|
|
7
|
+
import contract from "../../../../cms/site-content-contract.json";
|
|
8
|
+
|
|
9
|
+
export const dynamic = "force-dynamic";
|
|
10
|
+
export const revalidate = 0;
|
|
11
|
+
export const metadata: Metadata = {
|
|
12
|
+
title: "비급여 진료비 미리보기",
|
|
13
|
+
robots: { index: false, follow: false, noarchive: true },
|
|
14
|
+
referrer: "no-referrer",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default async function FeePreviewPage({ params, searchParams }: {
|
|
18
|
+
params: Promise<{ id: string }>;
|
|
19
|
+
searchParams: Promise<{ token?: string | string[] }>;
|
|
20
|
+
}) {
|
|
21
|
+
const [{ id }, { token }] = await Promise.all([params, searchParams]);
|
|
22
|
+
const connection = getRootAdminConnection();
|
|
23
|
+
if (!connection || typeof token !== "string" || !token || token.length > 4096) notFound();
|
|
24
|
+
// The API verifies the signed snapshot. Never log the token or fetch the published version here.
|
|
25
|
+
const post = await fetchPostPreview({ apiKey: connection.apiKey, baseUrl: connection.apiBase, token })
|
|
26
|
+
.catch(() => null);
|
|
27
|
+
const area = contract.contentAreas.find((item) => item.key === "non-covered-fees");
|
|
28
|
+
if (!post || post.id !== id || post.slug !== area?.pageSlug || !post.preview) notFound();
|
|
29
|
+
return <main id="main-content" className="reading-page">
|
|
30
|
+
<p role="status">미리보기입니다. 이 화면을 열어도 내용이 저장되거나 홈페이지에 공개되지 않습니다.</p>
|
|
31
|
+
<header className="page-heading"><h1>비급여 진료비 안내</h1><p>항목명이나 코드로 비용을 확인하세요. 금액의 단위는 원입니다.</p></header>
|
|
32
|
+
<NonCoveredFees {...await feeScheduleFromPost(post, true)} />
|
|
33
|
+
</main>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "page",
|
|
3
|
+
"slug": "non-covered-fees",
|
|
4
|
+
"title": "비급여 진료비",
|
|
5
|
+
"status": "draft",
|
|
6
|
+
"body_json": { "type": "doc", "content": [
|
|
7
|
+
{ "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "fees" }] },
|
|
8
|
+
{ "type": "paragraph", "content": [{ "type": "text", "text": "[]" }] },
|
|
9
|
+
{ "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "note" }] },
|
|
10
|
+
{ "type": "paragraph", "content": [] }
|
|
11
|
+
] }
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import { RootTaleAnalytics } from "@roottale/analytics-runtime/react";
|
|
5
|
+
import { RootTaleAnalyticsNextAdapter } from "@roottale/analytics-runtime/next";
|
|
6
|
+
import type { RootAnalyticsRuntime } from "../../lib/root-admin/analytics";
|
|
7
|
+
|
|
8
|
+
/** Draft URLs contain a preview token, so no analytics runtime is mounted on preview pages. */
|
|
9
|
+
export function SiteAnalytics({ runtime }: { runtime: RootAnalyticsRuntime }) {
|
|
10
|
+
const pathname = usePathname();
|
|
11
|
+
if (!pathname || pathname === "/preview" || pathname.startsWith("/preview/")) return null;
|
|
12
|
+
return <>
|
|
13
|
+
<RootTaleAnalytics manifest={runtime.manifest} collectUrl={runtime.collectUrl} siteId={runtime.siteId} />
|
|
14
|
+
<RootTaleAnalyticsNextAdapter />
|
|
15
|
+
</>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useId, useState } from "react";
|
|
3
|
+
import type { FeeGroup } from "../../../lib/root-admin/miridoc-fees";
|
|
4
|
+
import styles from "./nonCoveredFees.module.css";
|
|
5
|
+
|
|
6
|
+
const normalize = (value: string) => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, "");
|
|
7
|
+
export function NonCoveredFees({ groups, columns, note, source }: { groups: FeeGroup[]; columns: string[]; note: string;
|
|
8
|
+
source?: { name: string; dataset: string; via: string; url: string; checkedOn: string | null; notice: string } | null }) {
|
|
9
|
+
const id = useId(); const [query, setQuery] = useState(""); const [category, setCategory] = useState("");
|
|
10
|
+
const term = normalize(query);
|
|
11
|
+
const visible = groups.map((group) => ({ ...group, rows: group.rows.filter((row) => !row.hidden) })).filter((group) => group.rows.length);
|
|
12
|
+
const filtered = visible.filter((group) => !category || group.id === category).map((group) => ({ ...group,
|
|
13
|
+
rows: group.rows.filter((row) => !term || normalize(`${group.title} ${row.cells.join(" ")}`).includes(term)),
|
|
14
|
+
})).filter((group) => group.rows.length);
|
|
15
|
+
const count = filtered.reduce((sum, group) => sum + group.rows.length, 0);
|
|
16
|
+
return <section className={styles.schedule} aria-label="비급여 항목">
|
|
17
|
+
<div className={styles.filters}>
|
|
18
|
+
<label htmlFor={`${id}-search`}>항목명·코드 검색<input id={`${id}-search`} type="search" value={query} onChange={(event) => setQuery(event.target.value)} /></label>
|
|
19
|
+
<label htmlFor={`${id}-category`}>분류 선택<select id={`${id}-category`} value={category} onChange={(event) => setCategory(event.target.value)}><option value="">전체 분류</option>{visible.map((group) => <option key={group.id} value={group.id}>{group.title}</option>)}</select></label>
|
|
20
|
+
{query || category ? <button type="button" onClick={() => { setQuery(""); setCategory(""); }}>검색 초기화</button> : null}
|
|
21
|
+
</div>
|
|
22
|
+
<p role="status">{count}개 항목 · 단위: 원</p>
|
|
23
|
+
{filtered.map((group) => <section key={group.id}><h2>{group.title}</h2><div className={styles.scroll} role="region" aria-label={`${group.title} 비용표`} tabIndex={0}>
|
|
24
|
+
<table><caption>{group.title} (단위: 원)</caption><thead><tr>{columns.map((column, index) => <th key={index} scope="col">{column}</th>)}</tr></thead>
|
|
25
|
+
<tbody>{group.rows.map((row) => <tr key={row.id}>{row.cells.map((cell, index) => index === 1 ? <th key={index} scope="row">{cell}{row.origin === "hospital-override" ? <small> · 병원 수정</small> : null}</th> : <td key={index}>{cell}</td>)}</tr>)}</tbody>
|
|
26
|
+
</table></div></section>)}
|
|
27
|
+
{!count ? <p>{query || category ? "일치하는 항목이 없습니다. 다른 항목명이나 코드로 검색해 주세요." : "현재 공개된 비급여 항목이 없습니다."}</p> : null}
|
|
28
|
+
{note ? <p className={styles.note}>{note}</p> : null}
|
|
29
|
+
{source ? <div className={styles.note}><p>자동 연동 출처: {source.name} {source.dataset} · {source.via} 제공{source.checkedOn ? ` · 확인일 ${source.checkedOn}` : ""}</p>
|
|
30
|
+
<p>{source.notice} 병원에서 수정·추가한 내용은 병원 안내 기준입니다.</p><a href={source.url} target="_blank" rel="noreferrer">미리닥 원본 확인</a></div> : null}
|
|
31
|
+
</section>;
|
|
32
|
+
}
|
package/examples/nextjs/hospital-fees/files/features/site/non-covered/nonCoveredFees.module.css
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.schedule { display: grid; gap: 1.5rem; min-width: 0; }
|
|
2
|
+
.schedule > section { min-width: 0; }
|
|
3
|
+
.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 1rem; min-width: 0; }
|
|
4
|
+
.filters label { display: grid; gap: .5rem; flex: 1 1 12rem; min-width: 0; }
|
|
5
|
+
.filters input, .filters select, .filters button { min-height: 2.75rem; padding: .5rem .75rem; border: 1px solid currentColor; border-radius: .25rem; background: transparent; color: inherit; font: inherit; }
|
|
6
|
+
.filters input, .filters select { width: 100%; min-width: 0; }
|
|
7
|
+
.scroll { overflow-x: auto; max-width: 100%; }
|
|
8
|
+
.scroll table { width: 100%; min-width: 68rem; border-collapse: collapse; font-size: .875rem; }
|
|
9
|
+
.scroll th, .scroll td { padding: .75rem; border-bottom: 1px solid #cbd5e1; text-align: left; white-space: pre-line; }
|
|
10
|
+
.scroll thead { background: #f1f5f9; color: #0f172a; }
|
|
11
|
+
.note { white-space: pre-line; }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface FeeGroup { id: string; title: string; rows: { id: string; cells: string[]; hidden?: boolean }[] }
|
|
2
|
+
|
|
3
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function textContent(value: unknown): string {
|
|
7
|
+
if (value === undefined) return "";
|
|
8
|
+
if (!Array.isArray(value) || value.length > 1) throw new Error("비급여 입력값 오류");
|
|
9
|
+
if (!value.length) return "";
|
|
10
|
+
const node: unknown = value[0];
|
|
11
|
+
if (!record(node) || node.type !== "text" || typeof node.text !== "string") throw new Error("비급여 입력값 오류");
|
|
12
|
+
return node.text;
|
|
13
|
+
}
|
|
14
|
+
export function readFeeFields(body: unknown): Record<string, string> {
|
|
15
|
+
if (!record(body) || body.type !== "doc" || !Array.isArray(body.content) || body.content.length > 6000 || body.content.length % 2) throw new Error("비급여 문서 오류");
|
|
16
|
+
const blocks: unknown[] = body.content;
|
|
17
|
+
const values: Record<string, string> = {};
|
|
18
|
+
for (let i = 0; i < blocks.length; i += 2) {
|
|
19
|
+
const heading = blocks[i]; const paragraph = blocks[i + 1];
|
|
20
|
+
if (!record(heading) || heading.type !== "heading" || !record(heading.attrs) || heading.attrs.level !== 2 || !record(paragraph) || paragraph.type !== "paragraph") throw new Error("비급여 입력칸 오류");
|
|
21
|
+
const key = textContent(heading.content);
|
|
22
|
+
if (!/^[a-z][a-z0-9-]{0,79}$/.test(key) || Object.hasOwn(values, key)) throw new Error("비급여 입력칸 중복 또는 형식 오류");
|
|
23
|
+
values[key] = textContent(paragraph.content);
|
|
24
|
+
}
|
|
25
|
+
if (JSON.stringify(values).length > 500_000) throw new Error("비급여 문서 크기 초과");
|
|
26
|
+
return values;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Public table contract. Invalid input fails instead of publishing a partial price list. */
|
|
30
|
+
export function readFeeGroups(value: string, columnCount: number): FeeGroup[] {
|
|
31
|
+
if (value.length > 500_000) throw new Error("비급여 표 크기 초과");
|
|
32
|
+
const groups: unknown = JSON.parse(value);
|
|
33
|
+
if (!Array.isArray(groups) || groups.length > 100) throw new Error("비급여 분류 형식 오류");
|
|
34
|
+
const ids = new Set<string>(); let count = 0;
|
|
35
|
+
function identity(value: unknown) {
|
|
36
|
+
if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$/.test(value) || ids.has(value)) throw new Error("비급여 식별자 오류");
|
|
37
|
+
ids.add(value);
|
|
38
|
+
}
|
|
39
|
+
for (const group of groups) {
|
|
40
|
+
if (!group || typeof group !== "object" || typeof group.title !== "string" || !group.title.trim() || group.title.trim().length > 100 || !Array.isArray(group.rows)) throw new Error("비급여 분류 오류");
|
|
41
|
+
identity(group.id);
|
|
42
|
+
for (const row of group.rows) {
|
|
43
|
+
if (!row || !Array.isArray(row.cells) || row.cells.length !== columnCount ||
|
|
44
|
+
row.cells.some((cell: unknown) => typeof cell !== "string" || cell.length > 2000) || !row.cells[1]?.trim() ||
|
|
45
|
+
(row.hidden !== undefined && typeof row.hidden !== "boolean")) throw new Error("비급여 항목 오류");
|
|
46
|
+
identity(row.id);
|
|
47
|
+
if (++count > 1000) throw new Error("비급여 항목 수 초과");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return groups as FeeGroup[];
|
|
51
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { fetchMiridocFees } from "./miridoc-fees";
|
|
3
|
+
|
|
4
|
+
export async function loadMiridocFees(institutionId: string, preview = false) {
|
|
5
|
+
// Optional loopback-only fixture; customers cannot configure an arbitrary upstream URL.
|
|
6
|
+
const fixture = process.env.MIRIDOC_FEES_FIXTURE_ORIGIN;
|
|
7
|
+
if (fixture && !/^http:\/\/(127\.0\.0\.1|localhost):\d{1,5}$/.test(fixture)) throw new Error("비급여 검사 서버 주소 오류");
|
|
8
|
+
return fetchMiridocFees(institutionId, { fetchImpl: (url, init) => fetch(fixture ? `${fixture}${new URL(String(url)).pathname}` : url,
|
|
9
|
+
{ ...init, ...(preview ? { cache: "no-store" as const } : { next: { revalidate: 300, tags: ["non-covered-fees"] } }) }) });
|
|
10
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Portable public feed contract. Also distributed with the hospital website module. */
|
|
2
|
+
export type FeeRow = { id: string; cells: string[]; hidden?: boolean; origin?: 'miridoc' | 'hospital' | 'hospital-override' };
|
|
3
|
+
export type FeeGroup = { id: string; title: string; rows: FeeRow[] };
|
|
4
|
+
export interface MiridocFeeFeed {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
institution: { id: string; name: string; typeName: string; url: string };
|
|
7
|
+
status: 'available' | 'no_source' | 'not_loaded';
|
|
8
|
+
checkedOn: string | null;
|
|
9
|
+
rowCount: number;
|
|
10
|
+
groups: { id: string; title: string; rows: { id: string; code: string; name: string; label: string; amountWon: number; appliedOn: string }[] }[];
|
|
11
|
+
source: { name: string; dataset: string; via: string; url: string };
|
|
12
|
+
notice: string;
|
|
13
|
+
version: string;
|
|
14
|
+
}
|
|
15
|
+
const record = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
const text = (value: unknown, max = 2000): value is string => typeof value === 'string' && value.length <= max;
|
|
17
|
+
const date = (value: unknown): value is string => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
|
18
|
+
Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
19
|
+
export const isMiridocRow = (id: string) => /^md-[a-f0-9]{32}$/.test(id);
|
|
20
|
+
|
|
21
|
+
/** Reject partial/mismatched snapshots instead of quietly dropping prices. */
|
|
22
|
+
export function parseMiridocFees(value: unknown, institutionId: string): MiridocFeeFeed {
|
|
23
|
+
const fail = () => { throw new Error('미리닥 비급여 응답을 확인할 수 없습니다.'); };
|
|
24
|
+
if (!/^[a-z0-9]{6}$/.test(institutionId) || !record(value) || value.schemaVersion !== 1 ||
|
|
25
|
+
!record(value.institution) || value.institution.id !== institutionId || !text(value.institution.name, 200) ||
|
|
26
|
+
!text(value.institution.typeName, 100) || value.institution.url !== `https://miridoc.com/h/${institutionId}` ||
|
|
27
|
+
!['available', 'no_source', 'not_loaded'].includes(String(value.status)) ||
|
|
28
|
+
!(value.checkedOn === null || date(value.checkedOn)) || !Number.isInteger(value.rowCount) ||
|
|
29
|
+
!text(value.version, 64) || !/^[a-f0-9]{64}$/.test(value.version) || !text(value.notice) ||
|
|
30
|
+
!record(value.source) || value.source.name !== '건강보험심사평가원' || value.source.dataset !== '비급여진료비정보' ||
|
|
31
|
+
value.source.via !== '미리닥' || value.source.url !== value.institution.url || !Array.isArray(value.groups) || value.groups.length > 1000) return fail();
|
|
32
|
+
const ids = new Set<string>(); let count = 0;
|
|
33
|
+
for (const group of value.groups) {
|
|
34
|
+
if (!record(group) || !text(group.id, 100) || !/^mdg-[a-zA-Z0-9_-]{1,80}$/.test(group.id) || ids.has(group.id) ||
|
|
35
|
+
!text(group.title, 100) || !group.title.trim() || !Array.isArray(group.rows)) return fail();
|
|
36
|
+
ids.add(group.id);
|
|
37
|
+
for (const row of group.rows) {
|
|
38
|
+
if (!record(row) || !text(row.id, 100) || !isMiridocRow(row.id) || ids.has(row.id) ||
|
|
39
|
+
!text(row.code, 100) || !row.code || !text(row.name) || !row.name.trim() || !text(row.label) ||
|
|
40
|
+
typeof row.amountWon !== 'number' || !Number.isSafeInteger(row.amountWon) || row.amountWon < 0 || !date(row.appliedOn)) return fail();
|
|
41
|
+
ids.add(row.id); if (++count > 10000) return fail();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (count !== value.rowCount || (value.status === 'available' ? !count || !date(value.checkedOn) : count !== 0)) return fail();
|
|
45
|
+
return value as unknown as MiridocFeeFeed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function fetchMiridocFees(institutionId: string, options: { fetchImpl?: typeof fetch; signal?: AbortSignal } = {}) {
|
|
49
|
+
if (!/^[a-z0-9]{6}$/.test(institutionId)) throw new Error('미리닥 의료기관 ID를 확인해 주세요.');
|
|
50
|
+
const response = await (options.fetchImpl ?? fetch)(`https://miridoc.com/api/v1/institutions/${institutionId}/non-covered`, {
|
|
51
|
+
headers: { Accept: 'application/json' }, signal: options.signal ?? AbortSignal.timeout(8000), redirect: 'error',
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) throw new Error(`미리닥 비급여를 불러오지 못했습니다 (${response.status}).`);
|
|
54
|
+
// Bound memory before parsing external data; a failed feed must not become a partial list.
|
|
55
|
+
const reader = response.body?.getReader();
|
|
56
|
+
if (!reader) throw new Error('미리닥 응답이 비어 있습니다.');
|
|
57
|
+
const chunks: Uint8Array[] = []; let size = 0;
|
|
58
|
+
try {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
61
|
+
size += value.byteLength;
|
|
62
|
+
if (size > 8_000_000) throw new Error('미리닥 응답 크기를 초과했습니다.');
|
|
63
|
+
chunks.push(value);
|
|
64
|
+
}
|
|
65
|
+
} catch (error) { await reader.cancel(); throw error; } finally { reader.releaseLock(); }
|
|
66
|
+
const bytes = new Uint8Array(size); let offset = 0;
|
|
67
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
68
|
+
return parseMiridocFees(JSON.parse(new TextDecoder().decode(bytes)), institutionId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function miridocFeeGroups(feed: MiridocFeeFeed): FeeGroup[] {
|
|
72
|
+
return feed.groups.map(group => ({ id: group.id, title: group.title, rows: group.rows.map(row => ({
|
|
73
|
+
id: row.id,
|
|
74
|
+
cells: [group.title, row.name, row.code, row.label, row.amountWon.toLocaleString('ko-KR'), '', '', '', '', '', row.appliedOn],
|
|
75
|
+
})) }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Store only hospital edits/additions. Missing source rows are never resurrected by old overrides. */
|
|
79
|
+
export function mergeMiridocFees(feed: MiridocFeeFeed, local: FeeGroup[]): FeeGroup[] {
|
|
80
|
+
const edits = new Map(local.flatMap(group => group.rows).filter(row => isMiridocRow(row.id)).map(row => [row.id, row]));
|
|
81
|
+
const automatic = miridocFeeGroups(feed).map(group => ({ ...group, rows: group.rows.map(row => {
|
|
82
|
+
const edit = edits.get(row.id);
|
|
83
|
+
return edit ? { ...edit, origin: 'hospital-override' as const } : { ...row, origin: 'miridoc' as const };
|
|
84
|
+
}) }));
|
|
85
|
+
const added = local.map(group => ({ ...group, id: `local-${group.id}`, title: `병원 안내 · ${group.title}`,
|
|
86
|
+
rows: group.rows.filter(row => !isMiridocRow(row.id)).map(row => ({ ...row, origin: 'hospital' as const })),
|
|
87
|
+
})).filter(group => group.rows.length);
|
|
88
|
+
return [...automatic, ...added];
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { fetchPageBySlug } from "@roottale/cms-client/server";
|
|
3
|
+
import contract from "../../cms/site-content-contract.json";
|
|
4
|
+
import { getRootAdminConnection } from "./config";
|
|
5
|
+
import { readFeeFields, readFeeGroups } from "./content-table";
|
|
6
|
+
import { mergeMiridocFees } from "./miridoc-fees";
|
|
7
|
+
import { loadMiridocFees } from "./miridoc-client";
|
|
8
|
+
|
|
9
|
+
function feeContract() {
|
|
10
|
+
const area = contract.contentAreas.find((item) => item.key === "non-covered-fees");
|
|
11
|
+
const table = area?.editor.fields.find((field) => field.kind === "table");
|
|
12
|
+
if (!area || !table?.table) throw new Error("비급여 CMS 연결 계약이 필요합니다.");
|
|
13
|
+
return { area, table, columns: table.table.columns };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function feeScheduleFromPost(post: { bodyJson?: unknown } | null, preview = false) {
|
|
17
|
+
const { table, columns } = feeContract();
|
|
18
|
+
const values = post ? readFeeFields(post.bodyJson) : {};
|
|
19
|
+
if (post && !Object.hasOwn(values, table.key)) throw new Error("비급여 표 입력값이 없습니다.");
|
|
20
|
+
const local = post ? readFeeGroups(values[table.key], columns.length) : [];
|
|
21
|
+
const institutionId = table.table?.source?.institutionId;
|
|
22
|
+
const feed = institutionId ? await loadMiridocFees(institutionId, preview) : null;
|
|
23
|
+
const groups = (feed ? mergeMiridocFees(feed, local) : local)
|
|
24
|
+
.map(group => ({ ...group, rows: group.rows.filter(row => !row.hidden) })).filter(group => group.rows.length);
|
|
25
|
+
return { groups, columns, note: values.note ?? "비용은 병원에 문의해 주세요.",
|
|
26
|
+
source: feed ? { ...feed.source, checkedOn: feed.checkedOn, notice: feed.notice } : null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getNonCoveredFees() {
|
|
30
|
+
const { area } = feeContract();
|
|
31
|
+
const connection = getRootAdminConnection();
|
|
32
|
+
if (!connection) return feeScheduleFromPost(null);
|
|
33
|
+
// Network errors reach Next so a temporary outage cannot replace a published price list with empty data.
|
|
34
|
+
const post = await fetchPageBySlug({ apiKey: connection.apiKey, baseUrl: connection.apiBase,
|
|
35
|
+
slug: area.pageSlug, revalidate: 60, signal: AbortSignal.timeout(8000) });
|
|
36
|
+
return feeScheduleFromPost(post?.status === "published" ? post : null);
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { buildManifest } from "@roottale/analytics-runtime";
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({ preview: vi.fn(), published: vi.fn(), pathname: "/guide/non-covered" }));
|
|
6
|
+
vi.mock("server-only", () => ({}));
|
|
7
|
+
vi.mock("@roottale/cms-client/server", () => ({ fetchPostPreview: mocks.preview, fetchPageBySlug: mocks.published }));
|
|
8
|
+
vi.mock("next/navigation", () => ({ usePathname: () => mocks.pathname, notFound: () => { throw new Error("not found"); } }));
|
|
9
|
+
vi.mock("@roottale/analytics-runtime/react", () => ({ RootTaleAnalytics: () => <span>analytics-runtime</span> }));
|
|
10
|
+
vi.mock("@roottale/analytics-runtime/next", () => ({ RootTaleAnalyticsNextAdapter: () => <span>analytics-adapter</span> }));
|
|
11
|
+
vi.mock("../lib/root-admin/miridoc-client", () => ({ loadMiridocFees: async () => ({
|
|
12
|
+
groups: [], status: "no_source", checkedOn: null, source: {name:"건강보험심사평가원",dataset:"비급여진료비정보",via:"미리닥",url:"https://miridoc.com/h/t5rqt3"},notice:"자료 없음",
|
|
13
|
+
}) }));
|
|
14
|
+
const { default: PreviewPage, metadata } = await import("../app/preview/post/[id]/page");
|
|
15
|
+
const { SiteAnalytics } = await import("../components/site/SiteAnalytics");
|
|
16
|
+
const { readFeeGroups } = await import("../lib/root-admin/content-table");
|
|
17
|
+
const { feeScheduleFromPost } = await import("../lib/root-admin/non-covered-fees");
|
|
18
|
+
|
|
19
|
+
const groups = [{ id: "group", title: "검사", rows: [
|
|
20
|
+
{ id: "visible", cells: ["", "미리보기 항목", "0001", "", "98,765", "", "", "", "", "", ""] },
|
|
21
|
+
{ id: "hidden", cells: ["", "숨긴 항목", "0002", "", "0", "", "", "", "", "", ""], hidden: true },
|
|
22
|
+
] }];
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.stubEnv("ROOTTALE_API_KEY", "fixture");
|
|
25
|
+
mocks.preview.mockResolvedValue({ id: "fee", slug: "non-covered-fees", preview: { expiresAt: "2099-01-01", sourceStatus: "draft" },
|
|
26
|
+
bodyJson: { type: "doc", content: [
|
|
27
|
+
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "fees" }] },
|
|
28
|
+
{ type: "paragraph", content: [{ type: "text", text: JSON.stringify(groups) }] },
|
|
29
|
+
] } });
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); });
|
|
32
|
+
|
|
33
|
+
it("실제 미리보기 진입은 현재 초안의 표를 그리며 공개본을 다시 읽지 않는다", async () => {
|
|
34
|
+
const html = renderToStaticMarkup(await PreviewPage({ params: Promise.resolve({ id: "fee" }), searchParams: Promise.resolve({ token: "fixture" }) }));
|
|
35
|
+
expect(html).toContain("미리보기 항목");
|
|
36
|
+
expect(html).toContain("98,765");
|
|
37
|
+
expect(html).not.toContain("숨긴 항목");
|
|
38
|
+
expect(mocks.published).not.toHaveBeenCalled();
|
|
39
|
+
expect(metadata).toMatchObject({ robots: { index: false, follow: false }, referrer: "no-referrer" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("토큰 없이 또는 다른 글 ID로는 초안을 열 수 없다", async () => {
|
|
43
|
+
await expect(PreviewPage({ params: Promise.resolve({ id: "fee" }), searchParams: Promise.resolve({}) })).rejects.toThrow("not found");
|
|
44
|
+
expect(mocks.preview).not.toHaveBeenCalled();
|
|
45
|
+
await expect(PreviewPage({ params: Promise.resolve({ id: "other" }), searchParams: Promise.resolve({ token: "fixture" }) })).rejects.toThrow("not found");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("분석 설정이 있어도 미리보기에서 런타임과 페이지 추적을 실행하지 않는다", () => {
|
|
49
|
+
const runtime = { manifest: buildManifest({ siteId: "fixture", tags: [] }), siteId: "fixture", collectUrl: "/collect" };
|
|
50
|
+
mocks.pathname = "/preview/post/fee";
|
|
51
|
+
expect(renderToStaticMarkup(<SiteAnalytics runtime={runtime} />)).toBe("");
|
|
52
|
+
mocks.pathname = "/guide/non-covered";
|
|
53
|
+
const html = renderToStaticMarkup(<SiteAnalytics runtime={runtime} />);
|
|
54
|
+
expect(html).toContain("analytics-runtime");
|
|
55
|
+
expect(html).toContain("analytics-adapter");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("문자열 금액과 코드·정상 빈 표를 보존하며 잘못된 셀 수와 중복 ID는 거부한다", () => {
|
|
59
|
+
expect(readFeeGroups(JSON.stringify(groups), 11)[0].rows[0].cells.slice(2, 5)).toEqual(["0001", "", "98,765"]);
|
|
60
|
+
expect(readFeeGroups("[]", 11)).toEqual([]);
|
|
61
|
+
expect(() => readFeeGroups(JSON.stringify(groups), 10)).toThrow();
|
|
62
|
+
expect(() => readFeeGroups(JSON.stringify([...groups, ...groups]), 11)).toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("숨긴 금액은 브라우저로 전달하는 표 데이터에서도 제외한다", async () => {
|
|
66
|
+
const post = await mocks.preview();
|
|
67
|
+
const schedule = await feeScheduleFromPost(post, true);
|
|
68
|
+
expect(schedule.groups.flatMap(group => group.rows).map(row => row.id)).toEqual(["visible"]);
|
|
69
|
+
});
|