[feat] landing: 리드 수신 Google Apps Script 추가 (Slack 대체)
사내에서 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 <noreply@anthropic.com>
This commit is contained in:
parent
631a501c5c
commit
e809129ae9
99
landing/docs/lead-webhook.google-apps-script.js
Normal file
99
landing/docs/lead-webhook.google-apps-script.js
Normal file
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user