import type { VercelRequest, VercelResponse } from "@vercel/node" /* * 리드 수집 엔드포인트 (Vercel 서버리스 함수). * * 랜딩은 ssr:false 정적 빌드라 서버 라우트가 없다. 그래서 /api 디렉터리의 함수로 받는다. * 클라이언트는 같은 오리진으로 쏘므로 엔드포인트 환경변수도, CORS 설정도 필요 없다. * * ── 리드를 잃지 않는 것이 이 파일의 유일한 책임이다 ────────────────────────── * 기존 문의 폼은 1.2초 뒤 성공 화면만 띄우고 아무 데도 보내지 않았다. 화면은 * "접수되었습니다"인데 영업이 받을 리드는 없고, 아무도 그 사실을 몰랐다. * * 그래서 순서를 이렇게 잡는다. * 1) 먼저 구조화된 로그를 남긴다. 웹훅이 죽어도 Vercel 로그에 리드가 남는다. * 2) LEAD_WEBHOOK_URL 이 있으면 그리로 전달한다(Slack Incoming Webhook·Zapier·자체 API 모두 POST 를 받는다). * 3) 웹훅 전달이 실패해도 200 을 준다 — 1) 에서 이미 리드를 확보했으므로 방문자에게 * 다시 입력하라고 할 이유가 없다. 대신 실패는 로그에 크게 남긴다. * * 로그는 CRM 이 아니라 최후의 그물이다. LEAD_WEBHOOK_URL 은 반드시 설정해야 한다. */ const MAX_FIELD = 500 type LeadBody = { source?: string name?: string email?: string company?: string phone?: string message?: string /* 봇 함정. 사람 눈에 안 보이는 필드라 값이 차 있으면 자동 제출이다. */ website?: string } const clean = (v: unknown) => (typeof v === "string" ? v.trim().slice(0, MAX_FIELD) : "") /** 느슨한 검사. 정규식으로 이메일을 엄밀히 검증하려는 시도는 늘 진짜 주소를 막는다. */ const emailLike = (v: string) => v.length >= 5 && v.includes("@") && !v.startsWith("@") && !v.endsWith("@") && !/\s/.test(v) export default async function handler(req: VercelRequest, res: VercelResponse) { if (req.method !== "POST") { res.setHeader("Allow", "POST") return res.status(405).json({ ok: false, error: "method_not_allowed" }) } const body = (typeof req.body === "string" ? safeParse(req.body) : req.body) as LeadBody | null if (!body) return res.status(400).json({ ok: false, error: "invalid_json" }) // 봇은 조용히 돌려보낸다. 400 을 주면 어떤 필드가 함정인지 알려주는 셈이다. if (clean(body.website)) return res.status(200).json({ ok: true }) const lead = { source: clean(body.source) || "unknown", name: clean(body.name), email: clean(body.email), company: clean(body.company), phone: clean(body.phone), message: clean(body.message), submittedAt: new Date().toISOString(), userAgent: clean(req.headers["user-agent"]), } if (!lead.name || !emailLike(lead.email)) { return res.status(400).json({ ok: false, error: "invalid_input" }) } // 1) 무슨 일이 있어도 먼저 남긴다. console.log("[lead]", JSON.stringify(lead)) // 2) 사람이 실제로 보는 곳으로 전달. const webhook = process.env.LEAD_WEBHOOK_URL if (!webhook) { console.warn("[lead] LEAD_WEBHOOK_URL 미설정 — 리드가 로그에만 남습니다. 웹훅을 설정하세요.") return res.status(200).json({ ok: true, delivered: false }) } try { const upstream = await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, // Slack Incoming Webhook 은 text 를 읽고, 나머지 수신처는 보통 원본 필드를 읽는다. // 둘 다 담아 보내면 수신처를 바꿀 때 이 파일을 고칠 일이 없다. body: JSON.stringify({ text: summarize(lead), ...lead }), }) if (!upstream.ok) { console.error("[lead] 웹훅 전달 실패", upstream.status, JSON.stringify(lead)) return res.status(200).json({ ok: true, delivered: false }) } return res.status(200).json({ ok: true, delivered: true }) } catch (e) { console.error("[lead] 웹훅 예외", e, JSON.stringify(lead)) return res.status(200).json({ ok: true, delivered: false }) } } function safeParse(s: string) { try { return JSON.parse(s) } catch { return null } } function summarize(l: { source: string; name: string; email: string; company: string; phone: string; message: string }) { const rows = [ `*새 리드* (${l.source})`, `이름: ${l.name}`, `이메일: ${l.email}`, l.company && `회사: ${l.company}`, l.phone && `연락처: ${l.phone}`, l.message && `내용: ${l.message}`, ].filter(Boolean) return rows.join("\n") }