import { mkdir } from "node:fs/promises"; const GOOGLE_AUTH_SCOPE = "https://www.googleapis.com/auth/spreadsheets"; const TOKEN_PATH = ".tokens/google-oauth-token.json"; const DEFAULT_REDIRECT_PORT = 3487; const CALLBACK_PATH = "/oauth2callback"; const DEFAULT_TARGET_SHEET_URL = "https://docs.google.com/spreadsheets/d/1LGF93SGkrIqqniY-a3ReTPhizsEMtgm5QBC8tSALmVM/edit?gid=0#gid=0"; export const TARGET_HEADERS = [ "id", "created_time", "campaign_name", "form_name", "platform", "full_name", "phone_number", "site", "created_at", "activation_channel", "customer_group", "sales_point", "contact_number", "primary_product", "status", "key", ] as const; export type TargetHeader = (typeof TARGET_HEADERS)[number]; export type SheetRow = Record< string, string | number | boolean | null | undefined >; type TokenResponse = { access_token: string; expires_in: number; refresh_token?: string; scope: string; token_type: string; }; type SavedToken = { access_token: string; expiry_date: number; refresh_token: string; scope: string; token_type: string; }; type SpreadsheetMetadata = { sheets?: Array<{ properties?: { sheetId?: number; title?: string; }; }>; }; type SheetValuesResponse = { range?: string; majorDimension?: string; values?: string[][]; }; type ValueRangePayload = { range: string; majorDimension: "ROWS"; values: string[][]; }; type BatchUpdateSpreadsheetRequest = { requests: Array<{ addSheet?: { properties?: { title?: string; }; }; }>; }; type AppendRowsOptions = { rows: SheetRow[]; sheetName: string; baseDate?: string; targetSheetUrl?: string; }; const TARGET_HEADER_ALIASES: Record = { id: ["id"], created_time: ["created_time"], campaign_name: ["campaign_name", "campain_name"], form_name: ["form_name"], platform: ["platform"], full_name: ["full_name", "고객명"], phone_number: ["phone_number", "핸드폰연락처"], site: ["site"], created_at: ["created_at", "접수일자"], activation_channel: ["activation_channel", "개통처"], customer_group: ["customer_group", "고객그룹"], sales_point: ["sales_point", "판매점"], contact_number: ["contact_number", "phone_number", "핸드폰연락처"], primary_product: ["primary_product", "1차 상품"], status: ["status", "진행상태"], key: ["key", "고객키"], }; function readRequiredEnv(name: string): string { const value = Bun.env[name]?.trim(); if (!value) { throw new Error(`${name} 환경변수가 필요합니다.`); } return value; } function parseSpreadsheetUrl(input: string) { const url = new URL(input); const match = url.pathname.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); if (!match?.[1]) { throw new Error("유효한 구글 시트 URL이 아닙니다."); } const spreadsheetId = match[1]; const gidValue = url.searchParams.get("gid") ?? url.hash.match(/gid=(\d+)/)?.[1] ?? null; const gid = gidValue ? Number(gidValue) : null; if (gidValue && Number.isNaN(gid)) { throw new Error("gid 값을 해석하지 못했습니다."); } return { spreadsheetId, gid }; } function escapeSheetTitle(title: string): string { return `'${title.replaceAll("'", "''")}'`; } function normalizeHeader(header: string): string { return header.trim().toLowerCase().replaceAll(/\s+/g, "_"); } function stringifyCellValue(value: SheetRow[string]): string { if (value == null) { return ""; } return String(value).trim(); } function formatKstDateTime(value: string): string { const trimmed = value.trim(); if (!trimmed) { return ""; } // Keep date-only values as-is so KST conversion does not introduce 09:00:00. if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { return trimmed; } const date = new Date(trimmed); if (Number.isNaN(date.getTime())) { return value; } const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }) .formatToParts(date) .reduce>((acc, part) => { if (part.type !== "literal") { acc[part.type] = part.value; } return acc; }, {}); return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`; } function formatPhoneNumber(value: string): string { const trimmed = value.trim(); if (!trimmed) { return ""; } const normalized = trimmed.replace(/^p:/i, "").replace(/[^\d+]/g, ""); let digits = normalized; if (digits.startsWith("+82")) { digits = `0${digits.slice(3)}`; } else if (digits.startsWith("82")) { digits = `0${digits.slice(2)}`; } const phoneDigits = digits.replace(/\D/g, ""); if (phoneDigits.length === 10 && phoneDigits.startsWith("10")) { return `010-${phoneDigits.slice(2, 6)}-${phoneDigits.slice(6)}`; } if (phoneDigits.length === 11) { return `${phoneDigits.slice(0, 3)}-${phoneDigits.slice(3, 7)}-${phoneDigits.slice(7)}`; } return value; } function normalizeCellValue(header: string, value: string): string { const normalizedHeader = normalizeHeader(header); if ( normalizedHeader === "created_time" || normalizedHeader === "created_at" ) { return formatKstDateTime(value); } if ( normalizedHeader === "phone_number" || normalizedHeader === "contact_number" ) { return formatPhoneNumber(value); } return value; } function buildHeaderLookup(headers: string[]): Map { const lookup = new Map(); headers.forEach((header, index) => { const trimmed = header.trim() || `column_${index + 1}`; lookup.set(trimmed, trimmed); lookup.set(normalizeHeader(trimmed), trimmed); }); return lookup; } function getRowValueForTarget( row: SheetRow, targetHeader: TargetHeader ): string { const directValue = stringifyCellValue(row[targetHeader]); if (directValue) { return normalizeCellValue(targetHeader, directValue); } const lookup = buildHeaderLookup(Object.keys(row)); const mappedSourceHeader = lookup.get(targetHeader) ?? lookup.get(normalizeHeader(targetHeader)); if (mappedSourceHeader) { const mappedValue = stringifyCellValue(row[mappedSourceHeader]); if (mappedValue) { return normalizeCellValue(targetHeader, mappedValue); } } const aliases = TARGET_HEADER_ALIASES[targetHeader] ?? []; for (const alias of aliases) { const aliasValue = stringifyCellValue(row[alias]); if (aliasValue) { return normalizeCellValue(targetHeader, aliasValue); } const aliasHeader = lookup.get(alias) ?? lookup.get(normalizeHeader(alias)); if (aliasHeader) { const headerValue = stringifyCellValue(row[aliasHeader]); if (headerValue) { return normalizeCellValue(targetHeader, headerValue); } } } return ""; } export function mapRowsForTarget(rows: SheetRow[]): string[][] { return rows.map((row) => TARGET_HEADERS.map((header) => getRowValueForTarget(row, header)) ); } async function readSavedToken(): Promise { const tokenFile = Bun.file(TOKEN_PATH); if (!(await tokenFile.exists())) { return null; } return (await tokenFile.json()) as SavedToken; } async function saveToken(token: SavedToken) { await mkdir(".tokens", { recursive: true }); await Bun.write(TOKEN_PATH, JSON.stringify(token, null, 2)); } async function refreshAccessToken( clientId: string, clientSecret: string, refreshToken: string ): Promise { const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "refresh_token", refresh_token: refreshToken, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`토큰 갱신 실패: ${response.status} ${text}`); } const token = (await response.json()) as TokenResponse; return { access_token: token.access_token, expiry_date: Date.now() + token.expires_in * 1000, refresh_token: refreshToken, scope: token.scope, token_type: token.token_type, }; } async function requestNewToken( clientId: string, clientSecret: string ): Promise { const redirectPort = Number( Bun.env.GOOGLE_REDIRECT_PORT ?? DEFAULT_REDIRECT_PORT ); const redirectUri = `http://127.0.0.1:${redirectPort}${CALLBACK_PATH}`; const state = crypto.randomUUID(); const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); authUrl.searchParams.set("client_id", clientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", GOOGLE_AUTH_SCOPE); authUrl.searchParams.set("access_type", "offline"); authUrl.searchParams.set("prompt", "consent"); authUrl.searchParams.set("state", state); let resolveCode!: (value: string) => void; let rejectCode!: (reason?: unknown) => void; const codePromise = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; }); const server = Bun.serve({ port: redirectPort, fetch(request) { const url = new URL(request.url); if (url.pathname !== CALLBACK_PATH) { return new Response("Not found", { status: 404 }); } const returnedState = url.searchParams.get("state"); const code = url.searchParams.get("code"); const error = url.searchParams.get("error"); if (error) { rejectCode(new Error(`OAuth 인증 실패: ${error}`)); return new Response( "OAuth 인증이 취소되었습니다. 터미널을 확인해 주세요.", { status: 400, } ); } if (returnedState !== state || !code) { rejectCode(new Error("OAuth 콜백 검증에 실패했습니다.")); return new Response("잘못된 OAuth 콜백입니다.", { status: 400 }); } resolveCode(code); return new Response("인증이 완료되었습니다. 터미널로 돌아가 주세요."); }, }); console.log("브라우저에서 Google OAuth 인증을 진행해 주세요."); console.log(authUrl.toString()); try { Bun.file(authUrl.toString()); } catch { // URL은 이미 출력했으니 open 실패는 무시합니다. } let code: string; try { code = await Promise.race([ codePromise, new Promise((_, reject) => { setTimeout( () => reject(new Error("OAuth 인증 대기 시간이 초과되었습니다.")), 5 * 60 * 1000 ); }), ]); } finally { server.stop(true); } const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, grant_type: "authorization_code", redirect_uri: redirectUri, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`OAuth 토큰 발급 실패: ${response.status} ${text}`); } const token = (await response.json()) as TokenResponse; if (!token.refresh_token) { throw new Error( "refresh_token 을 받지 못했습니다. OAuth 클라이언트 설정을 확인해 주세요." ); } return { access_token: token.access_token, expiry_date: Date.now() + token.expires_in * 1000, refresh_token: token.refresh_token, scope: token.scope, token_type: token.token_type, }; } async function getAuthorizedToken(): Promise { const clientId = readRequiredEnv("GOOGLE_CLIENT_ID"); const clientSecret = readRequiredEnv("GOOGLE_CLIENT_SECRET"); const savedToken = await readSavedToken(); const hasWriteScope = savedToken?.scope?.includes(GOOGLE_AUTH_SCOPE) ?? false; if (savedToken?.refresh_token && hasWriteScope) { const needsRefresh = savedToken.expiry_date <= Date.now() + 60_000; if (!needsRefresh) { return savedToken; } const refreshed = await refreshAccessToken( clientId, clientSecret, savedToken.refresh_token ); await saveToken(refreshed); return refreshed; } if (savedToken?.refresh_token && !hasWriteScope) { console.log( "기존 토큰이 읽기 전용 scope 입니다. 브라우저에서 다시 OAuth 인증을 진행합니다." ); } const newToken = await requestNewToken(clientId, clientSecret); await saveToken(newToken); return newToken; } async function googleApiFetch(accessToken: string, url: string): Promise { const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { const text = await response.text(); throw new Error(`Google API 호출 실패: ${response.status} ${text}`); } return (await response.json()) as T; } async function googleApiRequest( accessToken: string, url: string, init?: RequestInit ): Promise { const response = await fetch(url, { ...init, headers: { Authorization: `Bearer ${accessToken}`, ...(init?.headers ?? {}), }, }); if (!response.ok) { const text = await response.text(); throw new Error(`Google API 호출 실패: ${response.status} ${text}`); } return (await response.json()) as T; } async function getSheetValues( accessToken: string, spreadsheetId: string, range: string ): Promise { const encodedRange = encodeURIComponent(range); return googleApiFetch( accessToken, `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}` ); } async function updateSheetValues( accessToken: string, spreadsheetId: string, range: string, values: string[][] ) { const encodedRange = encodeURIComponent(range); const body: ValueRangePayload = { range, majorDimension: "ROWS", values, }; return googleApiRequest( accessToken, `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), } ); } async function appendSheetValues( accessToken: string, spreadsheetId: string, range: string, values: string[][] ) { const encodedRange = encodeURIComponent(range); const body: ValueRangePayload = { range, majorDimension: "ROWS", values, }; return googleApiRequest( accessToken, `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), } ); } async function hasSheetTitle( accessToken: string, spreadsheetId: string, sheetTitle: string ): Promise { const metadata = await googleApiFetch( accessToken, `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=sheets.properties(title)` ); return ( metadata.sheets?.some((sheet) => sheet.properties?.title === sheetTitle) ?? false ); } async function createSheet( accessToken: string, spreadsheetId: string, sheetTitle: string ) { const body: BatchUpdateSpreadsheetRequest = { requests: [ { addSheet: { properties: { title: sheetTitle, }, }, }, ], }; return googleApiRequest( accessToken, `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), } ); } async function ensureSheetExists( accessToken: string, spreadsheetId: string, sheetTitle: string ) { const exists = await hasSheetTitle(accessToken, spreadsheetId, sheetTitle); if (!exists) { await createSheet(accessToken, spreadsheetId, sheetTitle); } } async function ensureTargetHeaderRow( accessToken: string, spreadsheetId: string, sheetTitle: string ) { const headerRange = `${escapeSheetTitle(sheetTitle)}!1:1`; const current = await getSheetValues(accessToken, spreadsheetId, headerRange); const existingHeaders = current.values?.[0] ?? []; const hasSameHeaders = existingHeaders.length === TARGET_HEADERS.length && TARGET_HEADERS.every((header, index) => existingHeaders[index] === header); if (!hasSameHeaders) { await updateSheetValues(accessToken, spreadsheetId, headerRange, [ Array.from(TARGET_HEADERS), ]); } } function getTodayKstDateString(): string { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit", }) .formatToParts(new Date()) .reduce>((acc, part) => { if (part.type !== "literal") { acc[part.type] = part.value; } return acc; }, {}); return `${parts.year}-${parts.month}-${parts.day}`; } function getMonthDayString(baseDate = getTodayKstDateString()): string { return baseDate.slice(5).replace("-", ""); } export async function appendRowsToDailySheet({ rows, sheetName, baseDate, targetSheetUrl = Bun.env.GOOGLE_TARGET_SHEET_URL ?? DEFAULT_TARGET_SHEET_URL, }: AppendRowsOptions) { const targetSheetTitle = `${sheetName}${getMonthDayString(baseDate)}`; if (rows.length === 0) { return { appendedRows: 0, targetSheetTitle, }; } const token = await getAuthorizedToken(); const targetSheet = parseSpreadsheetUrl(targetSheetUrl); const mappedRows = mapRowsForTarget(rows); if (mappedRows.length === 0) { return { appendedRows: 0, targetSheetTitle, }; } await ensureSheetExists( token.access_token, targetSheet.spreadsheetId, targetSheetTitle ); await ensureTargetHeaderRow( token.access_token, targetSheet.spreadsheetId, targetSheetTitle ); await appendSheetValues( token.access_token, targetSheet.spreadsheetId, escapeSheetTitle(targetSheetTitle), mappedRows ); return { appendedRows: mappedRows.length, targetSheetTitle, targetSpreadsheetId: targetSheet.spreadsheetId, }; }