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) +}