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 를 불러올 수 없다). * 채널·포맷 목록은 src/types/studio.ts 의 CHANNEL_OPTIONS 와 같아야 한다 (서버 함수는 src 의 TS 를 불러올 수 없다).
*/ */
import { safeParse } from './_origin.js';
export { guard } from './_origin.js';
export const CHANNELS = { export const CHANNELS = {
youtube: { label: 'YouTube', formats: { shorts: ['Shorts', '9:16'], long_form: ['Long-form', '16:9'] } }, youtube: { label: 'YouTube', formats: { shorts: ['Shorts', '9:16'], long_form: ['Long-form', '16:9'] } },
instagram: { instagram: {
@ -23,49 +27,6 @@ export const CHANNELS = {
export const PILLARS = ['safety', 'expertise', 'results', 'care']; 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. * 브라우저가 보낸 선택값을 목록과 대조한다. 목록 밖이면 null.
* @returns {{ channel: string, channelLabel: string, format: string, formatLabel: string, aspectRatio: string, pillarId: string | 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; 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 }; 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)과 병원별 접근 권한 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 항목을 어떻게 다룰지 결정 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 정책 정리 · 2026-10-07
-- 익명(anon) 키로 실제 병원 데이터를 읽을 수 있는 RLS 정책 정리
-- --
-- 확인한 사실 (2026-10-07, anon 키로 행 수만 조회): -- 확인한 사실 (2026-10-07, anon 키로 행 수만 조회):
-- marketing_reports 103 · clinics 12 · channel_snapshots 189 · analysis_runs 53 -- marketing_reports 103 · clinics 12 · analysis_runs 53 · channel_snapshots 189 · screenshots 112
-- supporter_builds 3 · supporter_inputs 2 행이 anon 키로 읽힌다. -- content_plans 6 · supporter_builds 3 · supporter_inputs 2 행이 anon 키로 읽혔다.
-- anon 키는 infinith-demo 번들에 공개로 들어 있다 (webforai.kr 번들에서는 2026-10-07 c2e7d5a 로 제거). -- anon 키는 webforai.kr 번들(2026-10-07 c2e7d5a 로 제거)과 infinith-demo 번들에 공개로 들어 있었다.
-- --
-- 실행 전 확인할 것 (haewon 결정 필요): -- 선행 조건 (2026-10-07 완료):
-- 1. infinith-demo 의 리포트·플랜·스튜디오·병원 확인 화면(/report, /plan, /clinic, /supporters, /build)은 -- infinith-demo 화면의 조회·쓰기를 서버 함수 api/db.js(service role)로 옮겼다.
-- anon 키로 이 테이블을 읽는다. 아래를 실행하면 그 화면들이 빈 화면이 된다. -- 워커(workers/supporters-build), Edge Function, scripts/* 는 service role 키를 쓴다. 이 SQL 의 영향을 받지 않는다.
-- 대안: 로그인(Supabase Auth) 도입 후 authenticated 로 바꾸거나, 읽기를 Edge Function(service role) 뒤로 옮긴다. --
-- 2. 운영 DB 변경이다. 실행 전에 영향 범위를 확인받고, Supabase 대시보드 SQL 편집기에서 실행한다. -- 되돌리기: 각 정책을 아래 주석의 마이그레이션 파일에 있는 CREATE POLICY 문으로 다시 만든다.
-- 3. 되돌리기: 각 정책을 원래 마이그레이션의 CREATE POLICY 문으로 다시 만들면 된다 (파일명은 아래 주석).
-- ════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════
begin; begin;
@ -36,6 +34,13 @@ drop policy if exists "public_read_registry" on public.clinic_registry;
-- 20260907_supporter_builds.sql -- 20260907_supporter_builds.sql
drop policy if exists "anon can read builds" on public.supporter_builds; 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 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")는 -- 스토리지 버킷의 공개 읽기(20260407_screenshots_storage.sql "public_read_screenshots")는
-- 병원 채널 스크린샷 이미지다. 버킷 공개 여부와 함께 별도로 결정한다. -- 병원 채널 스크린샷 이미지다. 버킷 공개 여부와 함께 별도로 결정한다.

View File

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

View File

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

View File

@ -7,7 +7,7 @@
* - 의학 검토·게시 승인은 사람 게이트다(§3-7). 후보만 보여주고 확정은 병원이 입력한다. * - 의학 검토·게시 승인은 사람 게이트다(§3-7). 후보만 보여주고 확정은 병원이 입력한다.
*/ */
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { supabase } from '../../lib/supabase'; import { db } from '../../lib/db';
type Build = { type Build = {
id: string; id: string;
@ -61,8 +61,8 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
let alive = true; let alive = true;
(async () => { (async () => {
const [b, i] = await Promise.all([ 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), db<Build[]>('builds.latest', { clinicId, fields: 'inputs' }),
supabase.from('supporter_inputs').select('key,post_id,value,input_by,created_at').eq('clinic_id', clinicId).order('created_at', { ascending: true }), db<InputRow[]>('inputs.list', { clinicId }),
]); ]);
if (!alive) return; if (!alive) return;
if (b.error || i.error) { setLoadError((b.error ?? i.error)?.message ?? '불러오기 실패'); 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) { async function save(key: string, value: Record<string, unknown>, postId?: string) {
setBusy(`${key}|${postId ?? ''}`); setMsg(null); 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); setBusy(null);
if (error) { setMsg(`저장 실패: ${error.message}`); return; } if (error) { setMsg(`저장 실패: ${error.message}`); return; }
setRows((prev) => [...prev, { key, post_id: postId ?? null, value, input_by: inputBy || null, created_at: new Date().toISOString() }]); 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 { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/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'; import { CheckFilled, CrossFilled, EyeFilled, WarningFilled, LinkExternalFilled } from '../icons/FilledIcons';
type Role = 'hero' | 'used' | 'stored'; type Role = 'hero' | 'used' | 'stored';
@ -67,8 +67,8 @@ export function ImageReviewPanel({ clinicId }: { clinicId: string }) {
let alive = true; let alive = true;
void (async () => { void (async () => {
const [b, inputs] = await Promise.all([ 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), db<Build[]>('builds.latest', { clinicId, fields: 'image' }),
supabase.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', 'image_review').order('created_at', { ascending: true }), db<Array<{ value: Record<string, unknown>; input_by: string | null; created_at: string }>>('inputs.list', { clinicId, key: 'image_review' }),
]); ]);
if (!alive) return; if (!alive) return;
if (b.error) setLoadError(b.error.message); if (b.error) setLoadError(b.error.message);
@ -103,11 +103,11 @@ export function ImageReviewPanel({ clinicId }: { clinicId: string }) {
async function save() { async function save() {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
const { error } = await supabase.from('supporter_inputs').insert({ const { error } = await db('inputs.add', {
clinic_id: clinicId, clinicId,
key: 'image_review', key: 'image_review',
value: { decisions, excludeImages: excluded.map((i) => i.id), total: items.length, decidedAt: new Date().toISOString() }, 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); setSaving(false);
if (error) { setSaveError(error.message); return; } if (error) { setSaveError(error.message); return; }

View File

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

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '../types/plan'; 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 { transformReportToPlan } from '../lib/transformPlan';
import { mockPlan } from '../data/mockPlan'; import { mockPlan } from '../data/mockPlan';
import { mockPlanBanobagi } from '../data/mockPlan_banobagi'; import { mockPlanBanobagi } from '../data/mockPlan_banobagi';
@ -125,21 +126,13 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
if (!clinicId) { if (!clinicId) {
// Try to find clinicId from analysis_runs by run/report ID // Try to find clinicId from analysis_runs by run/report ID
const { data: run } = await supabase const { data: run } = await db<{ clinic_id: string }>('run.clinicId', { id });
.from('analysis_runs')
.select('clinic_id')
.eq('id', id)
.single();
if (run) clinicId = run.clinic_id; if (run) clinicId = run.clinic_id;
} }
if (clinicId) { if (clinicId) {
// Fetch clinic info for plan metadata // Fetch clinic info for plan metadata
const { data: clinic } = await supabase const { data: clinic } = await db<{ name: string | null; name_en: string | null; url: string | null }>('clinic.byId', { id: clinicId });
.from('clinics')
.select('name, name_en, url')
.eq('id', clinicId)
.single();
if (clinic) { if (clinic) {
clinicName = clinic.name || ''; clinicName = clinic.name || '';
clinicNameEn = clinic.name_en || ''; 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 supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; 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) */ /** Common headers for Edge Function calls (includes JWT auth) */
const fnHeaders = () => ({ const fnHeaders = () => ({
@ -29,11 +30,8 @@ export async function generateMarketingReport(url: string, clinicName?: string)
} }
export async function fetchReportById(reportId: string) { export async function fetchReportById(reportId: string) {
const { data, error } = await supabase // 예전 Supabase 클라이언트처럼 행 형태를 호출하는 쪽이 정한다.
.from("marketing_reports") const { data, error } = await db<any>("report.byId", { id: reportId });
.select("*")
.eq("id", reportId)
.single();
if (error) throw new Error(`Failed to fetch report: ${error.message}`); if (error) throw new Error(`Failed to fetch report: ${error.message}`);
return data; return data;
@ -54,11 +52,10 @@ export interface PipelineStatus {
} }
export async function fetchPipelineStatus(reportId: string): Promise<PipelineStatus> { export async function fetchPipelineStatus(reportId: string): Promise<PipelineStatus> {
const { data, error } = await supabase const { data, error } = await db<{
.from("marketing_reports") id: string; status: string | null; clinic_name?: string;
.select("id, status, clinic_name, channel_data, report") channel_data: Record<string, unknown> | null; report: Record<string, unknown> | null;
.eq("id", reportId) }>("report.status", { id: reportId });
.single();
if (error || !data) throw new Error(`Report not found: ${error?.message}`); 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. * Returns the most recent is_active=true plan with all JSONB columns.
*/ */
export async function fetchActiveContentPlan(clinicId: string) { export async function fetchActiveContentPlan(clinicId: string) {
const { data, error } = await supabase const { data, error } = await db<any>("plan.active", { clinicId });
.from("content_plans")
.select("*")
.eq("clinic_id", clinicId)
.eq("is_active", true)
.order("created_at", { ascending: false })
.limit(1)
.single();
if (error) return null; if (error) return null;
return data; return data;
@ -262,31 +252,8 @@ export async function updateCalendarEntry(
entryId: string, entryId: string,
updates: Record<string, unknown>, updates: Record<string, unknown>,
) { ) {
// Read current calendar // 읽기·수정·저장을 서버 함수가 한 번에 한다.
const { data: plan, error: readErr } = await supabase const { error: writeErr } = await db("plan.updateEntry", { planId, entryId, updates });
.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);
if (writeErr) throw new Error(`Update failed: ${writeErr.message}`); if (writeErr) throw new Error(`Update failed: ${writeErr.message}`);
} }

View File

@ -8,7 +8,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router'; import { useParams, Link } from 'react-router';
import { motion } from 'motion/react'; 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 { BUILD_STEPS, STATE_LABEL, progressOf, stepStates, warningsOf } from '../lib/buildPhases';
import type { BuildState, PhaseRow } from '../lib/buildPhases'; import type { BuildState, PhaseRow } from '../lib/buildPhases';
import { CheckFilled, WarningFilled, EyeFilled, PrismFilled } from '../components/icons/FilledIcons'; import { CheckFilled, WarningFilled, EyeFilled, PrismFilled } from '../components/icons/FilledIcons';
@ -40,12 +40,7 @@ export default function SiteBuildPage() {
let alive = true; let alive = true;
async function tick() { async function tick() {
const { data, error } = await supabase const { data, error } = await db<Build[]>('builds.latest', { clinicId: id, fields: 'site' });
.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);
if (!alive) return; if (!alive) return;
if (error) setLoadError(error.message); if (error) setLoadError(error.message);
const row = (data?.[0] as Build) ?? null; const row = (data?.[0] as Build) ?? null;