[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:
Haewon Kam 2026-08-06 11:34:01 +09:00
parent 631a501c5c
commit e809129ae9

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