o2o-infinith-demo/supporters/scripts/apply_inputs.mjs
Haewon Kam 7e515b70ae feat(supporters): 승인 UI·입력 반영·워커 — §7-5 ClinicInputsPanel + supporter_builds/inputs 마이그레이션 + apply_inputs, §12-2 상단 고지·개설자 승인·색인 빌드 필터, §7-6 워커 E2E(뷰성형외과 URL → 5편 → 게이트 0오류)
- 게이트: DISCLOSURE_TOP_MISSING, APPROVED_*, UNAPPROVED_IN_INDEXABLE_BUILD, PRESS_VOICE 규칙화, 픽스처 추가 (16/16)
- 생성기: --site 옵션, 병원 확인 답변(clinicAnswers) 근거 [A]
- default_briefs.mjs 첫 배치 자동 기획, workers/supporters-build/run.mjs
- 랜딩: SAMPLE REPORT 라벨 "실제 사례"로 (haewon 지시)
- v2 §11-6·§11-7 기록, E2E 리포트

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 17:07:06 +09:00

142 lines
9.7 KiB
JavaScript

// 병원 확인 항목 입력(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 <file> 또는 --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);