Compare commits

..

4 Commits

Author SHA1 Message Date
a97a6391d0 Merge pull request '데모 DB 조회·쓰기를 서버 함수로 옮기고 익명 정책 정리' (#2) from feat/demo-writes-server-only into main 2026-10-07 08:15:48 +00:00
Haewon Kam
0d1fdd3430 docs: 익명 키 정책 정리 SQL 을 쓰기 정책까지 포함한 실행본으로 바꾼다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:12:34 +09:00
Haewon Kam
0be730f0f9 fix: 데모 화면의 DB 조회·쓰기를 서버 함수로 옮긴다
브라우저가 공개 키로 테이블을 직접 읽고 쓰던 구조를 없앤다.
- api/db.js: 정해진 작업만 서비스 키로 실행 (임의 쿼리·열 지정 불가)
- api/_origin.js: 서버 함수 공용 출처 검사, 공개 배포에서는 404
- src/lib/db.ts: 화면용 호출 함수, 반환 형태 { data, error } 유지
- 빌드 큐에 바로 넣던 예전 진단 신청 모달 삭제

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:09:29 +09:00
dd11173d81 Merge pull request 'webforai.kr 공개 전 정리, API 키 서버 이전, 번들 검사 추가' (#1) from feat/webforai-feature-flags into main
Reviewed-on: #1
2026-10-07 06:51:05 +00:00
15 changed files with 280 additions and 389 deletions

59
api/_origin.js Normal file
View File

@ -0,0 +1,59 @@
/**
* 서버 함수 공용 출처 검사 (Vercel 서버 함수 전용, '_' 로 시작해 주소로 열리지 않는다).
*
* infinith-demo 와 그 미리보기, 로컬 개발에서 온 요청만 받는다. 데모는 Vercel 배포 보호(팀원 로그인) 뒤에 있어
* 서버 함수도 로그인한 팀원만 부를 수 있다. 공개 배포(webforai.kr, VITE_SITE=discovery)에서는 내부 함수를 열지 않는다.
*/
const ALLOWED_ORIGINS = [
'https://infinith-demo.vercel.app',
'http://localhost:3000',
'http://127.0.0.1:3000',
];
/** 이 프로젝트의 미리보기 배포 주소. 미리보기는 Vercel 로그인(배포 보호) 뒤에 있어 팀원만 연다. */
const PREVIEW_ORIGIN = /^https:\/\/infinith-demo-[a-z0-9]+-o2odev-5530s-projects\.vercel\.app$/;
const isAllowedOrigin = (o) => ALLOWED_ORIGINS.includes(o) || PREVIEW_ORIGIN.test(o);
/**
* CORS·메서드·출처를 확인한다. 통과하면 null, 아니면 이미 응답을 보낸 뒤 true 를 돌려준다.
* @param {string[]} methods 허용 메서드
*/
export function guard(req, res, methods) {
if (process.env.VITE_SITE === 'discovery') {
res.status(404).json({ ok: false, error: 'not found' });
return true;
}
const origin = req.headers.origin || '';
const allowed = isAllowedOrigin(origin);
if (allowed) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', [...methods, 'OPTIONS'].join(', '));
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
if (req.method === 'OPTIONS') {
res.status(allowed ? 204 : 403).end();
return true;
}
if (!methods.includes(req.method)) {
res.status(405).json({ ok: false, error: 'method not allowed' });
return true;
}
// 같은 출처 GET 은 브라우저가 Origin 을 붙이지 않을 수 있어 Referer 로도 확인한다.
const referer = req.headers.referer || '';
const refOrigin = (referer.match(/^https?:\/\/[^/]+/) || [''])[0];
if (!allowed && !isAllowedOrigin(refOrigin)) {
res.status(403).json({ ok: false, error: 'forbidden origin' });
return true;
}
return null;
}
export function safeParse(s) {
try {
return JSON.parse(s);
} catch {
return null;
}
}

View File

@ -10,6 +10,10 @@
* 채널·포맷 목록은 src/types/studio.ts 의 CHANNEL_OPTIONS 와 같아야 한다 (서버 함수는 src 의 TS 를 불러올 수 없다).
*/
import { safeParse } from './_origin.js';
export { guard } from './_origin.js';
export const CHANNELS = {
youtube: { label: 'YouTube', formats: { shorts: ['Shorts', '9:16'], long_form: ['Long-form', '16:9'] } },
instagram: {
@ -23,49 +27,6 @@ export const CHANNELS = {
export const PILLARS = ['safety', 'expertise', 'results', 'care'];
/** 이 주소들에서 온 요청만 받는다. 키를 쓰는 유료 API 라 Origin 이 없는 요청(브라우저 밖 호출)도 받지 않는다. */
const ALLOWED_ORIGINS = [
'https://infinith-demo.vercel.app',
'http://localhost:3000',
'http://127.0.0.1:3000',
];
/** 이 프로젝트의 미리보기 배포 주소. 미리보기는 Vercel 로그인(배포 보호) 뒤에 있어 팀원만 연다. */
const PREVIEW_ORIGIN = /^https:\/\/infinith-demo-[a-z0-9]+-o2odev-5530s-projects\.vercel\.app$/;
const isAllowedOrigin = (o) => ALLOWED_ORIGINS.includes(o) || PREVIEW_ORIGIN.test(o);
/**
* CORS·메서드·출처를 확인한다. 통과하면 null, 아니면 이미 응답을 보낸 뒤 true 를 돌려준다.
* @param {string[]} methods 허용 메서드
*/
export function guard(req, res, methods) {
const origin = req.headers.origin || '';
const allowed = isAllowedOrigin(origin);
if (allowed) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', [...methods, 'OPTIONS'].join(', '));
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
if (req.method === 'OPTIONS') {
res.status(allowed ? 204 : 403).end();
return true;
}
if (!methods.includes(req.method)) {
res.status(405).json({ ok: false, error: 'method not allowed' });
return true;
}
// 같은 출처 GET 은 브라우저가 Origin 을 붙이지 않을 수 있어 Referer 로도 확인한다.
const referer = req.headers.referer || '';
const refOrigin = (referer.match(/^https?:\/\/[^/]+/) || [''])[0];
const sameSite = isAllowedOrigin(refOrigin);
if (!allowed && !sameSite) {
res.status(403).json({ ok: false, error: 'forbidden origin' });
return true;
}
return null;
}
/**
* 브라우저가 보낸 선택값을 목록과 대조한다. 목록 밖이면 null.
* @returns {{ channel: string, channelLabel: string, format: string, formatLabel: string, aspectRatio: string, pillarId: string | null } | null}
@ -79,11 +40,3 @@ export function readSelection(body) {
if (pillarId !== null && !PILLARS.includes(pillarId)) return null;
return { channel: b.channel, channelLabel: ch.label, format: b.format, formatLabel: fmt[0], aspectRatio: fmt[1], pillarId };
}
export function safeParse(s) {
try {
return JSON.parse(s);
} catch {
return null;
}
}

130
api/db.js Normal file
View File

@ -0,0 +1,130 @@
/**
* 데모 화면의 데이터베이스 조회·쓰기 (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' } });
}
}

View File

@ -40,6 +40,6 @@ infinith-demo 배포(VITE_SITE 없음)는 모든 화면이 그대로 열린다.
공개 배포에서 열려면 아래가 먼저 끝나야 한다. 스위치를 만들지 않은 이유다.
1. 로그인(Supabase Auth)과 병원별 접근 권한
2. 데이터베이스 익명 읽기 정리: `docs/security/2026-10-07_rls_anon_read_DRAFT.sql` (infinith-demo 화면 영향 확인 후 실행)
2. 데이터베이스 익명 읽기·쓰기 정리: 2026-10-07 실행 (`docs/security/2026-10-07_rls_anon_lockdown.sql`). 데모 화면은 서버 함수 `api/db.js` 로 조회한다
3. `src/lib/supabase.ts` 가 공개 번들에 들어가게 되므로 번들 검사 `secrets` 의 Supabase 항목을 어떻게 다룰지 결정
4. 진단 신청 모달(`DiscoveryLeadModal.tsx`, 현재 미사용)은 신청마다 자동 빌드 큐(`supporter_builds`)에 넣는다. 공개 폼에 다시 붙이려면 사람 승인 단계를 먼저 둔다.
4. 예전 진단 신청 모달(신청마다 자동 빌드 큐 `supporter_builds` 에 넣던 것)은 2026-10-07 삭제했다. 빌드 큐 쓰기는 이제 서버 함수(`api/db.js` 의 `builds.queue`)만 할 수 있다. 공개 폼에서 빌드를 받으려면 사람 승인 단계를 먼저 둔다.

View File

@ -1,18 +1,16 @@
-- ════════════════════════════════════════════════════════════════════════
-- 초안 (실행하지 않음) · 2026-10-07
-- 익명(anon) 키로 실제 병원 데이터를 읽을 수 있는 RLS 정책 정리
-- 익명(anon) 키로 테이블을 읽고 쓰던 RLS 정책 정리 · 2026-10-07
--
-- 확인한 사실 (2026-10-07, anon 키로 행 수만 조회):
-- marketing_reports 103 · clinics 12 · channel_snapshots 189 · analysis_runs 53
-- supporter_builds 3 · supporter_inputs 2 행이 anon 키로 읽힌다.
-- anon 키는 infinith-demo 번들에 공개로 들어 있다 (webforai.kr 번들에서는 2026-10-07 c2e7d5a 로 제거).
-- marketing_reports 103 · clinics 12 · analysis_runs 53 · channel_snapshots 189 · screenshots 112
-- content_plans 6 · supporter_builds 3 · supporter_inputs 2 행이 anon 키로 읽혔다.
-- anon 키는 webforai.kr 번들(2026-10-07 c2e7d5a 로 제거)과 infinith-demo 번들에 공개로 들어 있었다.
--
-- 실행 전 확인할 것 (haewon 결정 필요):
-- 1. infinith-demo 의 리포트·플랜·스튜디오·병원 확인 화면(/report, /plan, /clinic, /supporters, /build)은
-- anon 키로 이 테이블을 읽는다. 아래를 실행하면 그 화면들이 빈 화면이 된다.
-- 대안: 로그인(Supabase Auth) 도입 후 authenticated 로 바꾸거나, 읽기를 Edge Function(service role) 뒤로 옮긴다.
-- 2. 운영 DB 변경이다. 실행 전에 영향 범위를 확인받고, Supabase 대시보드 SQL 편집기에서 실행한다.
-- 3. 되돌리기: 각 정책을 원래 마이그레이션의 CREATE POLICY 문으로 다시 만들면 된다 (파일명은 아래 주석).
-- 선행 조건 (2026-10-07 완료):
-- infinith-demo 화면의 조회·쓰기를 서버 함수 api/db.js(service role)로 옮겼다.
-- 워커(workers/supporters-build), Edge Function, scripts/* 는 service role 키를 쓴다. 이 SQL 의 영향을 받지 않는다.
--
-- 되돌리기: 각 정책을 아래 주석의 마이그레이션 파일에 있는 CREATE POLICY 문으로 다시 만든다.
-- ════════════════════════════════════════════════════════════════════════
begin;
@ -36,6 +34,13 @@ drop policy if exists "public_read_registry" on public.clinic_registry;
-- 20260907_supporter_builds.sql
drop policy if exists "anon can read builds" on public.supporter_builds;
drop policy if exists "anon can read inputs" on public.supporter_inputs;
drop policy if exists "anon can insert inputs" on public.supporter_inputs;
-- 20260907_supporter_builds_queue.sql (익명 빌드 큐 등록: 등록되면 워커가 빌드·배포를 시작한다)
drop policy if exists "anon can queue builds" on public.supporter_builds;
-- 20260831_discovery_leads.sql (예전 진단 신청 폼. 지금은 쓰는 코드가 없다)
drop policy if exists "anon can insert discovery leads" on public.discovery_leads;
-- 스토리지 버킷의 공개 읽기(20260407_screenshots_storage.sql "public_read_screenshots")는
-- 병원 채널 스크린샷 이미지다. 버킷 공개 여부와 함께 별도로 결정한다.

View File

@ -3,9 +3,7 @@
*
* - 이름·회사·전화·이메일·문의내용 → Apps Script 웹앱 → 구글시트 적재 + 알림 메일
* - 전송 실패 시 성공한 척하지 않고 mailto 폴백을 보여준다 (정직성 규약).
* DiscoveryLeadModal 과 같은 규칙이다.
* - 화면 구조·색·버튼은 DiscoveryLeadModal 을 그대로 따른다. 같은 사이트에서 모달이
* 두 종류로 보이지 않게 하기 위해서다.
* - 공개 사이트의 문의·진단 요청은 모두 이 모달 하나로 받는다 (예전 진단 신청 모달은 2026-10-07 삭제).
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';

View File

@ -11,7 +11,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { motion } from 'motion/react';
import { supabase } from '../../lib/supabase';
import { db } from '../../lib/db';
import { STATE_LABEL } from '../../lib/buildPhases';
import type { BuildState } from '../../lib/buildPhases';
import { weakSpots } from '../../lib/buildCoverage';
@ -39,8 +39,7 @@ export function BuildPlanSection({ result, overall, summary }: Props) {
useEffect(() => {
let alive = true;
void (async () => {
const { data } = await supabase.from('supporter_builds').select('status')
.eq('clinic_id', result.id).order('created_at', { ascending: false }).limit(1);
const { data } = await db<Array<{ status: string }>>('builds.latest', { clinicId: result.id, fields: 'status' });
if (!alive) return;
setExisting((data?.[0]?.status as BuildState) ?? null);
setChecking(false);
@ -50,13 +49,11 @@ export function BuildPlanSection({ result, overall, summary }: Props) {
async function approve() {
setBusy(true); setError(null);
await supabase.from('supporter_inputs').insert({
clinic_id: result.id, key: 'report_approval',
await db('inputs.add', {
clinicId: result.id, key: 'report_approval',
value: { ...summary, weakSpots: weak.map((w) => w.id), approvedAt: new Date().toISOString() },
});
const { error: e } = await supabase.from('supporter_builds').insert({
clinic_id: result.id, clinic_name: result.clinicName, url: result.url, status: 'queued',
});
const { error: e } = await db('builds.queue', { clinicId: result.id, clinicName: result.clinicName, url: result.url });
setBusy(false);
if (e) { setError(e.message); return; }
navigate(`/build/${result.id}`);

View File

@ -7,7 +7,7 @@
* - 의학 검토·게시 승인은 사람 게이트다(§3-7). 후보만 보여주고 확정은 병원이 입력한다.
*/
import { useEffect, useMemo, useState } from 'react';
import { supabase } from '../../lib/supabase';
import { db } from '../../lib/db';
type Build = {
id: string;
@ -61,8 +61,8 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
let alive = true;
(async () => {
const [b, i] = await Promise.all([
supabase.from('supporter_builds').select('id,status,phase,preview_url,posts,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1),
supabase.from('supporter_inputs').select('key,post_id,value,input_by,created_at').eq('clinic_id', clinicId).order('created_at', { ascending: true }),
db<Build[]>('builds.latest', { clinicId, fields: 'inputs' }),
db<InputRow[]>('inputs.list', { clinicId }),
]);
if (!alive) return;
if (b.error || i.error) { setLoadError((b.error ?? i.error)?.message ?? '불러오기 실패'); return; }
@ -82,7 +82,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
async function save(key: string, value: Record<string, unknown>, postId?: string) {
setBusy(`${key}|${postId ?? ''}`); setMsg(null);
const { error } = await supabase.from('supporter_inputs').insert({ clinic_id: clinicId, key, post_id: postId ?? null, value, input_by: inputBy || null });
const { error } = await db('inputs.add', { clinicId, key, postId: postId ?? null, value, inputBy: inputBy || null });
setBusy(null);
if (error) { setMsg(`저장 실패: ${error.message}`); return; }
setRows((prev) => [...prev, { key, post_id: postId ?? null, value, input_by: inputBy || null, created_at: new Date().toISOString() }]);

View File

@ -1,227 +0,0 @@
/**
* "URL 입력으로 시작하기" 진단 신청 모달.
*
* - 병원 URL(필수) + 회신 연락처(필수) + 상호(선택) → Supabase discovery_leads insert
* - RLS는 anon insert만 허용 (조회는 service_role 전용)
* - 저장 실패 시 성공한 척하지 않고 mailto 폴백을 보여준다 (정직성 규약)
*/
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
import { supabase } from '../../lib/supabase';
import { DISCOVERY_CONTACT_EMAIL } from './discoveryContact';
interface DiscoveryLeadModalProps {
open: boolean;
onClose: () => void;
}
type SubmitState = 'idle' | 'submitting' | 'done' | 'error';
/** 스킴이 없으면 https://를 붙이고, 호스트에 점이 있어야 유효로 본다 */
const normalizeUrl = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const u = new URL(withScheme);
if (!u.hostname.includes('.')) return null;
return u.toString();
} catch {
return null;
}
};
export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModalProps) {
const [url, setUrl] = useState('');
const [clinicName, setClinicName] = useState('');
const [contact, setContact] = useState('');
const [state, setState] = useState<SubmitState>('idle');
const [fieldError, setFieldError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
// @types/react 미설치 프로젝트 방침이라 React.FormEvent 대신 구조적 타입 사용
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
const normalized = normalizeUrl(url);
if (!normalized) {
setFieldError('URL 형식을 확인해 주세요. 예: viewclinic.com');
return;
}
if (!contact.trim()) {
setFieldError('진단 결과를 받을 이메일 또는 전화번호를 입력해 주세요.');
return;
}
setFieldError(null);
setState('submitting');
// discovery_leads 는 anon 조회 정책이 없어 insert 반환값을 읽지 않는다.
const { error } = await supabase.from('discovery_leads').insert({
url: normalized,
clinic_name: clinicName.trim() || null,
contact: contact.trim(),
source: 'discovery_landing',
});
// 서포터즈 자동 빌드 큐(supporter_builds, status=queued). 워커(workers/supporters-build/poll.mjs)가 집어 간다.
// clinic_id 는 홈페이지 호스트에서 만든다 (www.k-wonjin.co.kr → k-wonjin). 큐 삽입이 실패해도 신청 자체는 접수된 것으로 본다.
if (!error) {
const host = (() => { try { return new URL(normalized).host; } catch { return normalized; } })();
const clinicId = host.replace(/^www\./, '').split('.')[0].toLowerCase().replace(/[^a-z0-9-]/g, '') || 'clinic';
await supabase.from('supporter_builds').insert({ clinic_id: clinicId, url: normalized, status: 'queued', clinic_name: clinicName.trim() || null });
}
setState(error ? 'error' : 'done');
};
const fallbackMailto = () => {
const body = `병원/업체 URL: ${url}\n상호: ${clinicName || '-'}\n회신 연락처: ${contact}`;
return `mailto:${DISCOVERY_CONTACT_EMAIL}?subject=${encodeURIComponent(
'[INFINITH AI Discovery] 무료 진단 신청',
)}&body=${encodeURIComponent(body)}`;
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="on-dark fixed inset-0 z-50 flex items-center justify-center px-6 bg-[#0A1128]/60 backdrop-blur-sm"
onClick={onClose}
data-no-print
>
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.98 }}
transition={{ duration: 0.25 }}
className="on-light relative w-full max-w-md bg-white rounded-2xl shadow-2xl p-8"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="무료 AEO/GEO 진단 신청"
>
{/* 닫기 (커스텀 filled SVG — 라인 아이콘 금지 규칙) */}
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M2.3 0.9 6 4.6 9.7 0.9a1 1 0 0 1 1.4 1.4L7.4 6l3.7 3.7a1 1 0 0 1-1.4 1.4L6 7.4 2.3 11.1a1 1 0 0 1-1.4-1.4L4.6 6 0.9 2.3A1 1 0 0 1 2.3 0.9Z"
fill="#0A1128"
/>
</svg>
</button>
{state === 'done' ? (
<div className="flex flex-col items-center text-center py-4">
<div className="w-14 h-14 rounded-full bg-[#F3F0FF] flex items-center justify-center mb-5">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M9.6 16.2 4.8 11.4a1.2 1.2 0 0 1 1.7-1.7l3.1 3.1 7.9-7.9a1.2 1.2 0 0 1 1.7 1.7l-8.8 8.8a1.2 1.2 0 0 1-1.7 0Z"
fill="#6C5CE7"
/>
</svg>
</div>
<h3 className="text-lg font-bold text-primary-900 mb-2">신청이 접수되었습니다</h3>
<p className="ui-body mb-6">
입력하신 URL을 실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다.
<br />
문의: {DISCOVERY_CONTACT_EMAIL}
</p>
<button
type="button"
onClick={onClose}
className="inline-flex items-center h-11 px-8 bg-gradient-to-r from-[#4F1DA1] to-[#021341] rounded-full text-sm font-bold text-white hover:opacity-90 transition-opacity"
>
확인
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<h3 className="text-xl font-bold text-primary-900 mb-1.5">URL 입력으로 시작하기</h3>
<p className="ui-body mb-6">
병원 홈페이지 URL만 입력하면 AI 검색 인용 가능성을 실측 진단합니다. 진단은
무료입니다.
</p>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
병원/업체 홈페이지 URL <span className="text-[#7C3A4B]">*</span>
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="viewclinic.com"
autoFocus
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)] text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
상호 <span className="text-slate-400 font-normal">(선택)</span>
</label>
<input
type="text"
value={clinicName}
onChange={(e) => setClinicName(e.target.value)}
placeholder="뷰성형외과"
className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
진단 결과 받을 이메일/전화 <span className="text-[#7C3A4B]">*</span>
</label>
<input
type="text"
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder="marketing@clinic.com"
className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
{fieldError && (
<p className="text-[13px] text-[#7C3A4B] bg-[#FFF0F0] border border-[#F5D5DC] rounded-lg px-3.5 py-2.5 mb-4">
{fieldError}
</p>
)}
{state === 'error' && (
<p className="text-[13px] leading-relaxed text-[#7C5C3A] bg-[#FFF6ED] border border-[#F5E0C5] rounded-lg px-3.5 py-2.5 mb-4">
저장에 실패했습니다. 아래 이메일로 직접 신청해 주세요.
<br />
<a href={fallbackMailto()} className="font-semibold underline">
{DISCOVERY_CONTACT_EMAIL}로 신청 메일 보내기
</a>
</p>
)}
<button
type="submit"
disabled={state === 'submitting'}
className="w-full inline-flex items-center justify-center gap-2 h-[52px] bg-gradient-to-r from-[#4F1DA1] to-[#021341] rounded-full text-base font-bold text-white hover:opacity-90 transition-opacity disabled:opacity-60"
>
{state === 'submitting' ? '접수 중…' : '무료 진단 신청'}
{state !== 'submitting' && <ArrowRight className="w-5 h-5" />}
</button>
<p className="text-xs text-slate-400 text-center mt-4">
입력 정보는 진단 회신 용도로만 사용합니다.
</p>
</form>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -7,7 +7,7 @@
*/
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { supabase } from '../../lib/supabase';
import { db } from '../../lib/db';
import { CheckFilled, CrossFilled, EyeFilled, WarningFilled, LinkExternalFilled } from '../icons/FilledIcons';
type Role = 'hero' | 'used' | 'stored';
@ -67,8 +67,8 @@ export function ImageReviewPanel({ clinicId }: { clinicId: string }) {
let alive = true;
void (async () => {
const [b, inputs] = await Promise.all([
supabase.from('supporter_builds').select('id,status,preview_url,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1),
supabase.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', 'image_review').order('created_at', { ascending: true }),
db<Build[]>('builds.latest', { clinicId, fields: 'image' }),
db<Array<{ value: Record<string, unknown>; input_by: string | null; created_at: string }>>('inputs.list', { clinicId, key: 'image_review' }),
]);
if (!alive) return;
if (b.error) setLoadError(b.error.message);
@ -103,11 +103,11 @@ export function ImageReviewPanel({ clinicId }: { clinicId: string }) {
async function save() {
setSaving(true);
setSaveError(null);
const { error } = await supabase.from('supporter_inputs').insert({
clinic_id: clinicId,
const { error } = await db('inputs.add', {
clinicId,
key: 'image_review',
value: { decisions, excludeImages: excluded.map((i) => i.id), total: items.length, decidedAt: new Date().toISOString() },
input_by: inputBy.trim() || null,
inputBy: inputBy.trim() || null,
});
setSaving(false);
if (error) { setSaveError(error.message); return; }

View File

@ -2,7 +2,8 @@ import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { MegaphoneFilled } from '../icons/FilledIcons';
import { supabase, triggerStrategyAdjustment } from '../../lib/supabase';
import { triggerStrategyAdjustment } from '../../lib/supabase';
import { db } from '../../lib/db';
interface StrategyAdjustmentSectionProps {
clinicId: string | null;
@ -68,13 +69,7 @@ export default function StrategyAdjustmentSection({ clinicId, planId }: Strategy
setIsLoading(true);
try {
// Load latest performance metrics
const { data: perf } = await supabase
.from('performance_metrics')
.select('kpi_progress, strategy_suggestions')
.eq('clinic_id', clinicId!)
.order('created_at', { ascending: false })
.limit(1)
.single();
const { data: perf } = await db<{ kpi_progress: unknown; strategy_suggestions: unknown }>('perf.latest', { clinicId: clinicId! });
if (perf) {
setKpiProgress((perf.kpi_progress as KpiProgress[]) || []);
@ -83,12 +78,7 @@ export default function StrategyAdjustmentSection({ clinicId, planId }: Strategy
}
// Load adjustment history
const { data: adj } = await supabase
.from('strategy_adjustments')
.select('id, adjustment_type, description, reason, created_at')
.eq('clinic_id', clinicId!)
.order('created_at', { ascending: false })
.limit(10);
const { data: adj } = await db<AdjustmentHistoryItem[]>('adjustments.list', { clinicId: clinicId! });
if (adj) setHistory(adj);
} catch {

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router';
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '../types/plan';
import { fetchReportById, fetchActiveContentPlan, supabase } from '../lib/supabase';
import { fetchReportById, fetchActiveContentPlan } from '../lib/supabase';
import { db } from '../lib/db';
import { transformReportToPlan } from '../lib/transformPlan';
import { mockPlan } from '../data/mockPlan';
import { mockPlanBanobagi } from '../data/mockPlan_banobagi';
@ -125,21 +126,13 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
if (!clinicId) {
// Try to find clinicId from analysis_runs by run/report ID
const { data: run } = await supabase
.from('analysis_runs')
.select('clinic_id')
.eq('id', id)
.single();
const { data: run } = await db<{ clinic_id: string }>('run.clinicId', { id });
if (run) clinicId = run.clinic_id;
}
if (clinicId) {
// Fetch clinic info for plan metadata
const { data: clinic } = await supabase
.from('clinics')
.select('name, name_en, url')
.eq('id', clinicId)
.single();
const { data: clinic } = await db<{ name: string | null; name_en: string | null; url: string | null }>('clinic.byId', { id: clinicId });
if (clinic) {
clinicName = clinic.name || '';
clinicNameEn = clinic.name_en || '';

31
src/lib/db.ts Normal file
View File

@ -0,0 +1,31 @@
/**
* 데이터베이스 조회·쓰기. 브라우저는 테이블에 직접 접근하지 않고 서버 함수(api/db.js)의 정해진 작업만 부른다.
* 공개 키로 테이블을 읽고 쓰던 구조를 없애기 위한 것이다 (2026-10-07). 반환 형태는 Supabase 클라이언트와 같은 { data, error }.
*/
export type DbOp =
| 'report.byId' | 'report.status'
| 'run.clinicId' | 'clinic.byId' | 'plan.active' | 'plan.updateEntry'
| 'perf.latest' | 'adjustments.list'
| 'builds.latest' | 'builds.queue' | 'inputs.list' | 'inputs.add';
export interface DbResult<T> {
data: T | null;
error: { message: string; code?: string | null } | null;
}
export async function db<T = unknown>(op: DbOp, params: Record<string, unknown> = {}): Promise<DbResult<T>> {
try {
const res = await fetch('/api/db', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op, params }),
});
const body = (await res.json().catch(() => null)) as DbResult<T> | null;
if (!body) return { data: null, error: { message: `서버 응답 오류 (${res.status})` } };
return body;
} catch (e) {
console.error(`[db] ${op} 연결 실패`, e);
return { data: null, error: { message: '서버에 연결할 수 없습니다' } };
}
}

View File

@ -1,9 +1,10 @@
import { createClient } from "@supabase/supabase-js";
import { db } from "./db";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// 테이블 조회·쓰기는 서버 함수(api/db.js)를 거친다 (src/lib/db.ts). 브라우저에는 테이블 권한이 없다.
// 아래 주소와 공개 키는 Edge Function 호출(fnHeaders)에만 쓴다.
/** Common headers for Edge Function calls (includes JWT auth) */
const fnHeaders = () => ({
@ -29,11 +30,8 @@ export async function generateMarketingReport(url: string, clinicName?: string)
}
export async function fetchReportById(reportId: string) {
const { data, error } = await supabase
.from("marketing_reports")
.select("*")
.eq("id", reportId)
.single();
// 예전 Supabase 클라이언트처럼 행 형태를 호출하는 쪽이 정한다.
const { data, error } = await db<any>("report.byId", { id: reportId });
if (error) throw new Error(`Failed to fetch report: ${error.message}`);
return data;
@ -54,11 +52,10 @@ export interface PipelineStatus {
}
export async function fetchPipelineStatus(reportId: string): Promise<PipelineStatus> {
const { data, error } = await supabase
.from("marketing_reports")
.select("id, status, clinic_name, channel_data, report")
.eq("id", reportId)
.single();
const { data, error } = await db<{
id: string; status: string | null; clinic_name?: string;
channel_data: Record<string, unknown> | null; report: Record<string, unknown> | null;
}>("report.status", { id: reportId });
if (error || !data) throw new Error(`Report not found: ${error?.message}`);
@ -240,14 +237,7 @@ export async function generateContentPlan(reportId: string, clinicId?: string, r
* Returns the most recent is_active=true plan with all JSONB columns.
*/
export async function fetchActiveContentPlan(clinicId: string) {
const { data, error } = await supabase
.from("content_plans")
.select("*")
.eq("clinic_id", clinicId)
.eq("is_active", true)
.order("created_at", { ascending: false })
.limit(1)
.single();
const { data, error } = await db<any>("plan.active", { clinicId });
if (error) return null;
return data;
@ -262,31 +252,8 @@ export async function updateCalendarEntry(
entryId: string,
updates: Record<string, unknown>,
) {
// Read current calendar
const { data: plan, error: readErr } = await supabase
.from("content_plans")
.select("calendar")
.eq("id", planId)
.single();
if (readErr || !plan) throw new Error(`Plan not found: ${readErr?.message}`);
const calendar = plan.calendar as { weeks: { entries: Record<string, unknown>[] }[] };
// Find and update entry
for (const week of calendar.weeks) {
for (let i = 0; i < week.entries.length; i++) {
if (week.entries[i].id === entryId) {
week.entries[i] = { ...week.entries[i], ...updates, isManualEdit: true };
break;
}
}
}
const { error: writeErr } = await supabase
.from("content_plans")
.update({ calendar })
.eq("id", planId);
// 읽기·수정·저장을 서버 함수가 한 번에 한다.
const { error: writeErr } = await db("plan.updateEntry", { planId, entryId, updates });
if (writeErr) throw new Error(`Update failed: ${writeErr.message}`);
}

View File

@ -8,7 +8,7 @@
import { useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router';
import { motion } from 'motion/react';
import { supabase } from '../lib/supabase';
import { db } from '../lib/db';
import { BUILD_STEPS, STATE_LABEL, progressOf, stepStates, warningsOf } from '../lib/buildPhases';
import type { BuildState, PhaseRow } from '../lib/buildPhases';
import { CheckFilled, WarningFilled, EyeFilled, PrismFilled } from '../components/icons/FilledIcons';
@ -40,12 +40,7 @@ export default function SiteBuildPage() {
let alive = true;
async function tick() {
const { data, error } = await supabase
.from('supporter_builds')
.select('id,clinic_id,clinic_name,status,phase,phases,posts,preview_url,error,updated_at')
.eq('clinic_id', id)
.order('created_at', { ascending: false })
.limit(1);
const { data, error } = await db<Build[]>('builds.latest', { clinicId: id, fields: 'site' });
if (!alive) return;
if (error) setLoadError(error.message);
const row = (data?.[0] as Build) ?? null;