From e809129ae9abc253f2055f0b4f3d7f2a754dc2ac Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Thu, 6 Aug 2026 11:34:01 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20landing:=20=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20Google=20Apps=20Script=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(Slack=20=EB=8C=80=EC=B2=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사내에서 Slack 을 쓰지 않아 수신처를 Google 로 잡는다. Apps Script 웹 앱이 POST 를 받아 스프레드시트에 적재한다 — 별도 서비스 가입 없이 이미 있는 Google 계정으로 끝나고, 영업이 시트를 그대로 열어 본다. api/lead.ts 는 고치지 않았다. 이미 LEAD_WEBHOOK_URL 로 JSON 을 POST 하고, Slack 용 text 와 원본 필드를 함께 실어 보내도록 만들어 둬서 수신처만 바꾸면 된다. 스크립트는 docs/ 에 둔다. landing/api/ 안에 두면 Vercel 이 /api 규약에 따라 이 .js 를 서버리스 함수로 빌드하려 들고, Apps Script 전역(SpreadsheetApp 등)을 쓰므로 배포가 깨진다. 스크립트가 처리하는 것 - 헤더 자동 생성·확장. 나중에 필드가 늘어도 스크립트를 다시 안 고쳐도 된다. - LockService 로 동시 제출이 같은 행에 겹쳐 쓰는 것을 막는다. - Slack 전용 요약(text)은 시트에서 제외 — 같은 내용이 개별 필드에 이미 있다. - doGet 은 배포 직후 생존 확인용. 설치 절차는 파일 상단 주석에 있다. 배포 후 Vercel 환경변수 LEAD_WEBHOOK_URL 설정 + 재배포가 필요하다 — 환경변수는 배포 시점에 주입된다. Co-Authored-By: Claude Opus 5 --- .../docs/lead-webhook.google-apps-script.js | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 landing/docs/lead-webhook.google-apps-script.js diff --git a/landing/docs/lead-webhook.google-apps-script.js b/landing/docs/lead-webhook.google-apps-script.js new file mode 100644 index 0000000..6e3c185 --- /dev/null +++ b/landing/docs/lead-webhook.google-apps-script.js @@ -0,0 +1,99 @@ +/* + * 리드 수신용 Google Apps Script — 데모 요청·상담 신청을 스프레드시트에 적재한다. + * + * 이 파일은 랜딩 번들에 포함되지 않는다. Google Apps Script 편집기에 붙여넣을 원본이고, + * 여기 두는 이유는 배포 후 이 코드가 어디에도 남지 않으면 나중에 아무도 손댈 수 없기 때문이다. + * + * ── 설치 (약 3분) ──────────────────────────────────────────────────────────── + * 1. 새 Google 스프레드시트를 만든다. 시트 이름은 그대로 둬도 된다. + * 2. 확장 프로그램 → Apps Script → 기본 코드를 지우고 이 파일 전체를 붙여넣는다. + * 3. 배포 → 새 배포 → 유형 "웹 앱" + * 실행 계정 : 나 + * 액세스 권한 : 모든 사용자 ← 이걸 "나"로 두면 랜딩에서 호출이 막힌다 + * 4. 배포하면 나오는 URL(https://script.google.com/macros/s/…/exec)을 복사한다. + * 5. Vercel → negotium-landing → Settings → Environment Variables 에 + * 이름 LEAD_WEBHOOK_URL / 값 복사한 URL / Production 체크 + * 6. 다시 배포한다. 환경변수는 배포 시점에 주입되므로 재배포 전에는 적용되지 않는다. + * + * ── 동작 ──────────────────────────────────────────────────────────────────── + * api/lead.ts 가 아래 형태로 POST 한다. 필드가 늘어도 헤더를 자동으로 확장하므로 + * 이 스크립트를 다시 고칠 일은 거의 없다. + * { text, source, name, email, company, phone, message, submittedAt, userAgent } + * + * 주의: 웹 앱은 POST 에 302 를 돌려주고 script.googleusercontent.com 으로 넘긴다. + * api/lead.ts 의 fetch 는 리다이렉트를 따라가므로 최종 200 을 받는다 — 정상이다. + */ + +/** 리드가 쌓일 시트 이름. 없으면 자동 생성한다. */ +var SHEET_NAME = 'leads' + +/** 항상 이 순서로 왼쪽부터 채운다. 나머지 필드는 뒤에 자동으로 붙는다. */ +var PREFERRED = ['submittedAt', 'source', 'name', 'email', 'company', 'phone', 'message', 'userAgent'] + +function doPost(e) { + try { + var payload = JSON.parse((e && e.postData && e.postData.contents) || '{}') + + // text 는 Slack 전용 요약이라 시트에는 넣지 않는다. 같은 내용이 개별 필드에 이미 있다. + delete payload.text + + var lock = LockService.getScriptLock() + lock.waitLock(20000) // 동시 제출이 같은 행에 겹쳐 쓰는 것을 막는다 + try { + var sheet = getSheet_() + var header = ensureHeader_(sheet, payload) + var row = header.map(function (key) { + return payload[key] === undefined ? '' : payload[key] + }) + sheet.appendRow(row) + } finally { + lock.releaseLock() + } + + return json_({ ok: true }) + } catch (err) { + // 실패해도 랜딩 쪽 api/lead.ts 가 이미 로그를 남겼으므로 리드 자체는 보존된다. + return json_({ ok: false, error: String(err) }) + } +} + +/** 브라우저로 URL 을 열었을 때 살아있는지 확인용. 배포 직후 점검에 쓴다. */ +function doGet() { + return json_({ ok: true, service: 'negotium lead sink' }) +} + +function getSheet_() { + var ss = SpreadsheetApp.getActiveSpreadsheet() + return ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME) +} + +/** 헤더가 없으면 만들고, 처음 보는 필드가 오면 열을 덧붙인다. */ +function ensureHeader_(sheet, payload) { + var lastCol = sheet.getLastColumn() + var header = lastCol ? sheet.getRange(1, 1, 1, lastCol).getValues()[0].filter(String) : [] + + if (!header.length) { + header = PREFERRED.filter(function (k) { + return k in payload + }) + Object.keys(payload).forEach(function (k) { + if (header.indexOf(k) === -1) header.push(k) + }) + sheet.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold') + sheet.setFrozenRows(1) + return header + } + + var added = Object.keys(payload).filter(function (k) { + return header.indexOf(k) === -1 + }) + if (added.length) { + sheet.getRange(1, header.length + 1, 1, added.length).setValues([added]).setFontWeight('bold') + header = header.concat(added) + } + return header +} + +function json_(obj) { + return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON) +}