// 병원 확인 항목 입력(v2 §6, supporter_inputs) → 데이터 파일·글 frontmatter 반영. 멱등. // // node scripts/apply_inputs.mjs --clinic viewclinic --inputs inputs.json 로컬 파일 (배열 또는 {rows:[...]}) // node scripts/apply_inputs.mjs --clinic viewclinic --from-supabase SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY 로 조회 // --today YYYY-MM-DD --dry-run // // 행 형식: { clinic_id, key, post_id?, value, input_by?, created_at } // author { name, role, sameAs[], email } → authors.supporters[site.editorId] // editor { name, email } → site.editorId 사람 이름·corrections.owner/contact // sponsorship { notice } → site.sponsorNotice (글 첫 부분 고지, §12-2) // specialty_doctors { eye:'dr-x', nose:'dr-y', contour:..., lifting:..., anesthesia:... } → authors.physicians[id].specialty // domain { domain, indexableFrom, homepageLinkPosition } → site.domain / site.indexableFrom / site.homepageLink // quote_items · recovery · revision_policy · discrepancy → src/data/clinicAnswers.json (생성기 근거 [A], 화면 노출 없음) // post_review post_id + { reviewer, reviewedAt } → frontmatter reviewer·reviewStatus=reviewed·reviewedAt + history // post_approval post_id + { approvedBy, approvedAt } → frontmatter approvedBy·approvedAt + history (reviewed 전이면 거부) // 같은 (key, post_id) 는 created_at 최신 행만 쓴다. 코드 수정 없이 데이터만으로 검토 완료·스키마가 켜지는 것이 목표(v2 §10). import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import yaml from 'js-yaml'; import * as R from './gate/rules.mjs'; const here = (p) => fileURLToPath(new URL(p, import.meta.url)); const SUP = here('../'); const args = process.argv.slice(2); const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; }; const flag = (k) => args.includes(`--${k}`); const clinic = opt('clinic', 'viewclinic'); const today = opt('today', new Date().toISOString().slice(0, 10)); const dryRun = flag('dry-run'); const DATA = join(SUP, 'src/data'); const POSTS = join(SUP, 'src/content/posts'); (function loadEnv() { let dir = SUP; for (let i = 0; i < 4; i++) { const p = join(dir, '.env'); if (existsSync(p)) for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } dir = dirname(dir); } })(); // ---------- 입력 읽기 ---------- async function loadRows() { if (opt('inputs')) { const j = JSON.parse(readFileSync(resolve(opt('inputs')), 'utf8')); return Array.isArray(j) ? j : j.rows ?? []; } if (flag('from-supabase')) { const url = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.VITE_SUPABASE_ANON_KEY; if (!url || !key) throw new Error('SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 없음'); const res = await fetch(`${url}/rest/v1/supporter_inputs?clinic_id=eq.${encodeURIComponent(clinic)}&order=created_at.asc`, { headers: { apikey: key, authorization: `Bearer ${key}` } }); if (!res.ok) throw new Error(`supabase ${res.status} ${await res.text()}`); return await res.json(); } throw new Error('--inputs 또는 --from-supabase 가 필요합니다'); } const rows = (await loadRows()).filter((r) => !r.clinic_id || r.clinic_id === clinic).sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? ''))); const latest = new Map(); // key|post_id → row for (const r of rows) latest.set(`${r.key}|${r.post_id ?? ''}`, r); const get = (key, postId = '') => latest.get(`${key}|${postId}`)?.value; // ---------- 데이터 파일 ---------- const readJson = (f) => JSON.parse(readFileSync(join(DATA, f), 'utf8')); const authors = readJson('authors.json'); const site = readJson('site.json'); const corrections = existsSync(join(DATA, 'corrections.json')) ? readJson('corrections.json') : { items: [] }; const answers = existsSync(join(DATA, 'clinicAnswers.json')) ? readJson('clinicAnswers.json') : { _comment: '병원이 서면으로 확인해 준 답변. 생성기가 근거 [A]로 쓴다. 화면에는 직접 노출하지 않는다.', items: [] }; const changes = []; const note = (s) => changes.push(s); // 1. 작성자·편집 책임·정정 이메일 const author = get('author'); if (author) { const id = site.editorId || Object.keys(authors.supporters ?? {})[0] || 'editor'; authors.supporters = authors.supporters ?? {}; const cur = authors.supporters[id] ?? {}; authors.supporters[id] = { ...cur, name: author.name ?? cur.name, role: author.role ?? cur.role, email: author.email ?? cur.email, sameAs: author.sameAs ?? cur.sameAs ?? [] }; site.editorId = id; note(`author → supporters.${id} (${author.name})`); } const editor = get('editor'); if (editor) { corrections.owner = editor.name ?? corrections.owner; corrections.contact = editor.email ?? corrections.contact; note(`editor → corrections.owner/contact`); } // 3. 지원 관계 문구 (글 첫 부분 고지) const sponsorship = get('sponsorship'); if (sponsorship?.notice) { site.sponsorNotice = sponsorship.notice; note('sponsorship → site.sponsorNotice'); } // 8. 진료 분야별 담당 원장 const spec = get('specialty_doctors'); if (spec) { const LABEL = { eye: '눈성형', nose: '코성형', contour: '안면윤곽', lifting: '리프팅', breast: '가슴성형', anesthesia: '마취', revision: '재수술' }; for (const [field, id] of Object.entries(spec)) { if (!id || !authors.physicians?.[id]) { note(`specialty_doctors.${field}: "${id}" 는 physicians 에 없음 (건너뜀)`); continue; } authors.physicians[id].specialty = LABEL[field] ?? field; note(`specialty_doctors.${field} → ${id}`); } } // 9. 도메인·색인 시점·홈페이지 링크 위치 const domain = get('domain'); if (domain) { Object.assign(site, { domain: domain.domain ?? site.domain, indexableFrom: domain.indexableFrom ?? site.indexableFrom, homepageLink: domain.homepageLinkPosition ?? site.homepageLink }); note('domain → site.domain/indexableFrom/homepageLink'); } // 4~7. 자유 답변 → clinicAnswers (근거 [A]) const QUESTION = { quote_items: '견적 기본 포함 항목과 결제 방식', recovery: '회복 일정표의 병원 확정값(출근 기준 등)', revision_policy: '재수술 정책(타원 수술 상담 가능 여부, 부위별 대기 기간)', discrepancy: '페이지 간 표기 불일치의 정답' }; for (const key of ['quote_items', 'recovery', 'revision_policy', 'discrepancy']) { const v = get(key); if (!v) continue; const row = latest.get(`${key}|`); const item = { key, question: v.question ?? QUESTION[key], answer: typeof v === 'string' ? v : v.answer ?? JSON.stringify(v), at: String(row.created_at ?? today).slice(0, 10), by: row.input_by ?? '병원' }; answers.items = (answers.items ?? []).filter((x) => x.key !== key).concat(item); note(`${key} → clinicAnswers`); } // 2. 글별 의학 검토 · 개설자 게시 승인 → frontmatter const postFiles = existsSync(POSTS) ? readdirSync(POSTS).filter((f) => f.endsWith('.md')) : []; for (const f of postFiles) { const id = f.replace(/\.md$/, ''); const review = get('post_review', id); const approval = get('post_approval', id); if (!review && !approval) continue; const src = readFileSync(join(POSTS, f), 'utf8'); const { data, body } = R.parseMarkdown(src); let changed = false; if (review?.reviewer && review?.reviewedAt) { if (!authors.physicians?.[review.reviewer]) { note(`${id}: reviewer "${review.reviewer}" 가 physicians 에 없음 (건너뜀)`); } else if (data.reviewer !== review.reviewer || data.reviewedAt !== review.reviewedAt || data.reviewStatus !== 'reviewed') { data.reviewer = review.reviewer; data.reviewedAt = review.reviewedAt; data.reviewStatus = 'reviewed'; data.dateModified = today; data.history = [...(data.history ?? []), { date: review.reviewedAt, note: `담당 원장 의학 검토 완료 (${authors.physicians[review.reviewer].name})` }]; changed = true; note(`${id}: 의학 검토 ${review.reviewer} ${review.reviewedAt}`); } } if (approval?.approvedBy && approval?.approvedAt) { if (data.reviewStatus !== 'reviewed') note(`${id}: 의학 검토 전이라 게시 승인 보류`); else if (data.approvedAt !== approval.approvedAt || data.approvedBy !== approval.approvedBy) { data.approvedBy = approval.approvedBy; data.approvedAt = approval.approvedAt; data.dateModified = today; data.history = [...(data.history ?? []), { date: approval.approvedAt, note: `게시 승인 (${approval.approvedBy})` }]; changed = true; note(`${id}: 게시 승인 ${approval.approvedBy} ${approval.approvedAt}`); } } if (changed && !dryRun) writeFileSync(join(POSTS, f), `---\n${yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"' })}---\n${body.trim()}\n`); } // ---------- 저장 ---------- if (!dryRun) { writeFileSync(join(DATA, 'authors.json'), JSON.stringify(authors, null, 2) + '\n'); writeFileSync(join(DATA, 'site.json'), JSON.stringify(site, null, 2) + '\n'); writeFileSync(join(DATA, 'corrections.json'), JSON.stringify(corrections, null, 2) + '\n'); writeFileSync(join(DATA, 'clinicAnswers.json'), JSON.stringify(answers, null, 2) + '\n'); } console.log(`입력 ${rows.length}행 · 유효 ${latest.size}건 · 반영 ${changes.length}건${dryRun ? ' (dry-run)' : ''}`); for (const c of changes) console.log(' ·', c);