131 lines
7.3 KiB
JavaScript
131 lines
7.3 KiB
JavaScript
/**
|
|
* 데모 화면의 데이터베이스 조회·쓰기 (Vercel 서버 함수). POST { op, params } → { data, error }
|
|
*
|
|
* 왜 두는가
|
|
* 브라우저가 공개 키(anon)로 테이블을 직접 읽고 쓰던 구조라, 공개 키를 가진 누구나 병원 리포트를 내려받고
|
|
* 빌드 큐·병원 입력값을 쓸 수 있었다 (2026-10-07 확인). 조회·쓰기를 이 함수로 옮기고 익명 규칙(RLS)을 지운다.
|
|
* docs/security/2026-10-07_rls_anon_lockdown.sql
|
|
*
|
|
* 원칙
|
|
* - 서비스 키(SUPABASE_SERVICE_ROLE_KEY)는 서버 환경변수에만 둔다.
|
|
* - 임의 쿼리를 받지 않는다. 아래 OPS 에 적힌 작업만, 정해진 열·조건으로 실행한다.
|
|
* - 출처 검사(api/_origin.js): infinith-demo·미리보기·localhost 만. 데모는 Vercel 배포 보호 뒤에 있다.
|
|
*/
|
|
import { createClient } from '@supabase/supabase-js';
|
|
import { guard, safeParse } from './_origin.js';
|
|
|
|
const ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
const INPUT_KEYS = new Set([
|
|
'author', 'editor', 'sponsorship', 'quote_items', 'recovery', 'diet_guide', 'revision_policy', 'discrepancy',
|
|
'specialty_doctors', 'domain', 'post_review', 'post_approval', 'report_approval', 'image_review',
|
|
]);
|
|
const MAX_VALUE_BYTES = 50_000;
|
|
|
|
/** 화면마다 읽는 빌드 열. 브라우저가 열 이름을 정하지 않는다. */
|
|
const BUILD_FIELDS = {
|
|
status: 'status',
|
|
inputs: 'id,status,phase,preview_url,posts,report,updated_at',
|
|
image: 'id,status,preview_url,report,updated_at',
|
|
site: 'id,clinic_id,clinic_name,status,phase,phases,posts,preview_url,error,updated_at',
|
|
};
|
|
|
|
class BadRequest extends Error {}
|
|
const need = (cond, msg) => { if (!cond) throw new BadRequest(msg); };
|
|
const id = (v, name = 'id') => { need(typeof v === 'string' && ID.test(v), `bad ${name}`); return v; };
|
|
const uuidish = (v, name = 'id') => { need(typeof v === 'string' && /^[0-9a-f-]{8,64}$/i.test(v), `bad ${name}`); return v; };
|
|
const obj = (v, name) => {
|
|
need(v && typeof v === 'object' && !Array.isArray(v), `bad ${name}`);
|
|
need(JSON.stringify(v).length <= MAX_VALUE_BYTES, `${name} too large`);
|
|
return v;
|
|
};
|
|
|
|
/** 작업 목록. 각 작업은 supabase 결과({ data, error })를 돌려준다. */
|
|
const OPS = {
|
|
// 리포트 (src/lib/supabase.ts)
|
|
'report.byId': (sb, p) => sb.from('marketing_reports').select('*').eq('id', uuidish(p.id)).single(),
|
|
'report.status': (sb, p) => sb.from('marketing_reports').select('id, status, clinic_name, channel_data, report').eq('id', uuidish(p.id)).single(),
|
|
|
|
// 마케팅 플랜 (src/hooks/useMarketingPlan.ts, src/lib/supabase.ts)
|
|
'run.clinicId': (sb, p) => sb.from('analysis_runs').select('clinic_id').eq('id', uuidish(p.id)).single(),
|
|
'clinic.byId': (sb, p) => sb.from('clinics').select('name, name_en, url').eq('id', uuidish(p.id)).single(),
|
|
'plan.active': (sb, p) => sb.from('content_plans').select('*').eq('clinic_id', uuidish(p.clinicId, 'clinicId'))
|
|
.eq('is_active', true).order('created_at', { ascending: false }).limit(1).single(),
|
|
'plan.updateEntry': async (sb, p) => {
|
|
const planId = uuidish(p.planId, 'planId');
|
|
need(typeof p.entryId === 'string' && p.entryId.length <= 120, 'bad entryId');
|
|
const updates = obj(p.updates, 'updates');
|
|
const { data: plan, error } = await sb.from('content_plans').select('calendar').eq('id', planId).single();
|
|
if (error || !plan) return { data: null, error: error ?? { message: 'plan not found' } };
|
|
for (const week of plan.calendar?.weeks ?? []) {
|
|
for (let i = 0; i < (week.entries ?? []).length; i++) {
|
|
if (week.entries[i].id === p.entryId) week.entries[i] = { ...week.entries[i], ...updates, isManualEdit: true };
|
|
}
|
|
}
|
|
return sb.from('content_plans').update({ calendar: plan.calendar }).eq('id', planId);
|
|
},
|
|
|
|
// 전략 조정 (src/components/plan/StrategyAdjustmentSection.tsx)
|
|
'perf.latest': (sb, p) => sb.from('performance_metrics').select('kpi_progress, strategy_suggestions')
|
|
.eq('clinic_id', uuidish(p.clinicId, 'clinicId')).order('created_at', { ascending: false }).limit(1).single(),
|
|
'adjustments.list': (sb, p) => sb.from('strategy_adjustments').select('id, adjustment_type, description, reason, created_at')
|
|
.eq('clinic_id', uuidish(p.clinicId, 'clinicId')).order('created_at', { ascending: false }).limit(10),
|
|
|
|
// 서포터즈 빌드·병원 입력 (discovery 화면들)
|
|
'builds.latest': (sb, p) => {
|
|
const fields = BUILD_FIELDS[p.fields];
|
|
need(fields, 'bad fields');
|
|
return sb.from('supporter_builds').select(fields).eq('clinic_id', id(p.clinicId, 'clinicId'))
|
|
.order('created_at', { ascending: false }).limit(1);
|
|
},
|
|
'builds.queue': (sb, p) => {
|
|
need(typeof p.url === 'string' && /^https?:\/\/[^\s]{3,500}$/.test(p.url), 'bad url');
|
|
need(p.clinicName == null || (typeof p.clinicName === 'string' && p.clinicName.length <= 200), 'bad clinicName');
|
|
return sb.from('supporter_builds').insert({ clinic_id: id(p.clinicId, 'clinicId'), clinic_name: p.clinicName ?? null, url: p.url, status: 'queued' });
|
|
},
|
|
'inputs.list': (sb, p) => {
|
|
const clinicId = id(p.clinicId, 'clinicId');
|
|
if (p.key != null) {
|
|
need(INPUT_KEYS.has(p.key), 'bad key');
|
|
return sb.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', p.key)
|
|
.order('created_at', { ascending: true });
|
|
}
|
|
return sb.from('supporter_inputs').select('key,post_id,value,input_by,created_at').eq('clinic_id', clinicId)
|
|
.order('created_at', { ascending: true });
|
|
},
|
|
'inputs.add': (sb, p) => {
|
|
need(INPUT_KEYS.has(p.key), 'bad key');
|
|
need(p.postId == null || (typeof p.postId === 'string' && p.postId.length <= 200), 'bad postId');
|
|
need(p.inputBy == null || (typeof p.inputBy === 'string' && p.inputBy.length <= 100), 'bad inputBy');
|
|
return sb.from('supporter_inputs').insert({
|
|
clinic_id: id(p.clinicId, 'clinicId'), key: p.key, post_id: p.postId ?? null, value: obj(p.value, 'value'), input_by: p.inputBy || null,
|
|
});
|
|
},
|
|
};
|
|
|
|
export default async function handler(req, res) {
|
|
if (guard(req, res, ['POST'])) return;
|
|
|
|
const url = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL;
|
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
if (!url || !key) {
|
|
console.error('[api/db] SUPABASE_URL 또는 SUPABASE_SERVICE_ROLE_KEY 가 설정되지 않았습니다.');
|
|
return res.status(500).json({ data: null, error: { message: 'unconfigured' } });
|
|
}
|
|
|
|
const body = typeof req.body === 'string' ? safeParse(req.body) : req.body ?? {};
|
|
const op = OPS[body?.op];
|
|
if (!op) return res.status(400).json({ data: null, error: { message: 'unknown op' } });
|
|
|
|
try {
|
|
const sb = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
|
|
const { data, error } = await op(sb, body.params ?? {});
|
|
// 조회 실패·행 없음은 화면이 지금처럼 처리하도록 그대로 넘긴다. 상세 원인은 서버 로그에만 남긴다.
|
|
if (error) console.error(`[api/db] ${body.op} 실패`, error.code ?? '', error.message ?? '');
|
|
return res.status(200).json({ data: data ?? null, error: error ? { message: error.message ?? 'error', code: error.code ?? null } : null });
|
|
} catch (e) {
|
|
if (e instanceof BadRequest) return res.status(400).json({ data: null, error: { message: e.message } });
|
|
console.error(`[api/db] ${body.op} 예외`, e);
|
|
return res.status(500).json({ data: null, error: { message: 'server error' } });
|
|
}
|
|
}
|