From b87dce16a883809f3a0f4ac0f1615299d26b3d1a Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Sat, 9 May 2026 10:11:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(backend):=20full=20Apps=20Script=20Web=20A?= =?UTF-8?q?pp=20=E2=80=94=20/api/me,=20/api/measurement,=20/api/podbor=20+?= =?UTF-8?q?=20Claude=20integration=20+=20Telegram=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Code.gs | 650 +++++++++++++++++++++++++++++++++++++++++++--- backend/README.md | 89 +++++-- 2 files changed, 677 insertions(+), 62 deletions(-) diff --git a/backend/Code.gs b/backend/Code.gs index 6e7a62f..31171f8 100644 --- a/backend/Code.gs +++ b/backend/Code.gs @@ -1,17 +1,25 @@ /** * ЗОВ — Backend (Google Apps Script Web App) * - * Деплой: Deploy → New deployment → Type: Web app - * Execute as: Me - * Who has access: Anyone (только так MiniApp сможет POST'ить) + * Точки входа: + * POST {WEBAPP_URL}?path=me → handleMe + * POST {WEBAPP_URL}?path=measurement → handleMeasurement + * POST {WEBAPP_URL}?path=podbor → handlePodbor + * GET {WEBAPP_URL} → ping (health check) + * + * Тело POST: JSON { initData: "tg-init-data", ...payload } * * Script Properties (Project Settings → Script Properties): - * - BOT_TOKEN - * - ANTHROPIC_API_KEY - * - ADMIN_TG_ID - * - SHEET_ID + * BOT_TOKEN — токен Telegram-бота (для проверки initData и отправки уведомлений) + * ANTHROPIC_API_KEY — ключ Claude API + * ANTHROPIC_MODEL — опционально, модель (default: claude-haiku-4-5-20251001) + * ADMIN_TG_ID — tg_id куратора, кому слать алерты об ошибках */ +// ================================================================= +// 1. Entry & routing +// ================================================================= + function doPost(e) { try { const path = (e.parameter && e.parameter.path) || ""; @@ -21,52 +29,624 @@ function doPost(e) { let result; switch (path) { - case "me": - result = handleMe(body); - break; - case "measurement": - result = handleMeasurement(body); - break; - case "podbor": - result = handlePodbor(body); - break; + case "me": result = handleMe(body); break; + case "measurement": result = handleMeasurement(body); break; + case "podbor": result = handlePodbor(body); break; + case "ping": result = { pong: true, time: new Date().toISOString() }; break; default: - return jsonResponse({ error: "unknown_path", path }, 404); + return jsonResponse({ error: "unknown_path", path }); } return jsonResponse(result); } catch (err) { - return jsonResponse({ error: String(err) }, 500); + log("api_error", null, { path: e.parameter && e.parameter.path, error: String(err), stack: err.stack }); + return jsonResponse({ error: String(err) }); } } -function jsonResponse(obj, _status) { +function doGet() { + return jsonResponse({ + status: "ok", + service: "zov-tech-backend", + time: new Date().toISOString(), + }); +} + +function jsonResponse(obj) { return ContentService .createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); } -// =============== handlers =============== +// ================================================================= +// 2. Auth — Telegram WebApp initData verification +// ================================================================= + +function verifyInitData(initData) { + if (!initData) return null; + const props = PropertiesService.getScriptProperties(); + const botToken = props.getProperty("BOT_TOKEN"); + if (!botToken) throw new Error("BOT_TOKEN not in Script Properties"); + + const params = parseInitData(initData); + const receivedHash = params["hash"]; + if (!receivedHash) return null; + delete params["hash"]; + + const keys = Object.keys(params).sort(); + const dataCheckString = keys.map(k => k + "=" + params[k]).join("\n"); + + const tokenBytes = Utilities.newBlob(botToken).getBytes(); + const wadBytes = Utilities.newBlob("WebAppData").getBytes(); + const dcsBytes = Utilities.newBlob(dataCheckString).getBytes(); + + const secretKey = Utilities.computeHmacSha256Signature(tokenBytes, wadBytes); + const computedBytes = Utilities.computeHmacSha256Signature(dcsBytes, secretKey); + const computedHash = bytesToHex(computedBytes); + + if (computedHash !== receivedHash) return null; + + // 24-hour freshness check + const authDate = parseInt(params["auth_date"] || "0", 10); + const ageSec = Math.floor(Date.now() / 1000) - authDate; + if (ageSec > 86400) return null; + + let user = null; + try { user = JSON.parse(params["user"] || "null"); } catch (e) {} + + return { + user, + auth_date: authDate, + start_param: params["start_param"] || null, + chat_instance: params["chat_instance"] || null, + }; +} + +function parseInitData(initData) { + const result = {}; + initData.split("&").forEach(pair => { + const idx = pair.indexOf("="); + if (idx < 0) return; + const key = decodeURIComponent(pair.slice(0, idx)); + const val = decodeURIComponent(pair.slice(idx + 1)); + result[key] = val; + }); + return result; +} + +function bytesToHex(bytes) { + return bytes.map(b => { + const v = (b < 0 ? b + 256 : b).toString(16); + return v.length === 1 ? "0" + v : v; + }).join(""); +} + +// ================================================================= +// 3. Handlers +// ================================================================= function handleMe(body) { - // TODO: - // 1. Проверить hash в body.initData по BOT_TOKEN. - // 2. Извлечь tg_id из initData. - // 3. Найти пользователя в Sheet "Users" / "Managers" / "Clients". - // 4. Вернуть профиль + статус. - return { error: "not_implemented" }; + const auth = verifyInitData(body.initData); + if (!auth || !auth.user || !auth.user.id) { + return { error: "invalid_init_data" }; + } + const tgId = auth.user.id; + + // Регистрируем пользователя если первый раз, обновляем last_seen_at + const startParam = body.startParam || auth.start_param; + const user = getOrCreateUser(auth.user, startParam); + + if (user.role === "manager") { + const m = getManagerProfile(tgId) || synthesizeManagerFromUser(user); + return { + role: "manager", + user: { + tg_id: tgId, + full_name: m.full_name || user.full_name, + salon: m.salon || "", + avatar_initial: getInitial(m.full_name || user.first_name), + }, + status: m.status || "lapsed", + status_until: m.active_until ? formatDate(m.active_until) : null, + }; + } + + // client + const c = getClientProfile(tgId); + let manager = null; + if (c && c.manager_tg_id) { + const mp = getManagerProfile(c.manager_tg_id); + manager = mp ? { full_name: mp.full_name, salon: mp.salon } : null; + } + return { + role: "client", + user: { + tg_id: tgId, + full_name: (c && c.full_name) || user.full_name, + avatar_initial: getInitial((c && c.full_name) || user.first_name), + }, + manager, + }; } function handleMeasurement(body) { - // TODO: сохранить замер в Sheet "Measurements", уведомить менеджера. - return { error: "not_implemented" }; + const auth = verifyInitData(body.initData); + if (!auth || !auth.user || !auth.user.id) return { error: "invalid_init_data" }; + const tgId = auth.user.id; + const user = findUser(tgId); + if (!user) return { error: "user_not_found" }; + + const m = body.measurement || {}; + const id = generateId(); + const now = new Date(); + const filledBy = user.role === "manager" ? "manager_for_client" : "client_self"; + const clientTgId = user.role === "client" ? tgId : (m.client_tg_id || ""); + const c = user.role === "client" ? getClientProfile(tgId) : null; + const managerTgId = user.role === "manager" ? tgId : (c && c.manager_tg_id) || ""; + + appendRow("Measurements", [ + id, + now, + clientTgId, + managerTgId, + filledBy, + m.layout || "", + m.area_m2 || "", + m.ceiling_mm || "", + JSON.stringify(m.walls || {}), + JSON.stringify(m.openings || {}), + JSON.stringify(m.infra || {}), + JSON.stringify(m.niches || {}), + (m.photos || []).join(","), + m.notes || "", + "submitted", + ]); + + // Обновляем last_measurement_id у клиента + if (clientTgId) { + updateColumnByKey("Clients", "tg_id", clientTgId, "last_measurement_id", id); + } + + // Уведомляем менеджера, если замер сделал клиент + if (filledBy === "client_self" && managerTgId) { + sendTelegram( + managerTgId, + `📐 Новый замер от клиента ${user.full_name || tgId}.\n` + + `Площадь: ${m.area_m2 || "?"} м², форма: ${m.layout || "?"}.\n` + + `Открыть в кабинете для просмотра.` + ); + } + + log("measurement_submitted", tgId, { id, filledBy }); + return { ok: true, id }; } function handlePodbor(body) { - // TODO: - // 1. Сохранить заявку в Sheet "Leads". - // 2. Собрать prompt из body.checklist + measurement. - // 3. Вызвать Claude API. - // 4. Записать ответ. - // 5. Отправить менеджеру через Telegram Bot API. - return { error: "not_implemented" }; + const auth = verifyInitData(body.initData); + if (!auth || !auth.user || !auth.user.id) return { error: "invalid_init_data" }; + const tgId = auth.user.id; + const user = findUser(tgId); + if (!user) return { error: "user_not_found" }; + if (user.role !== "manager") return { error: "only_manager_can_request_podbor" }; + + const checklist = body.checklist || {}; + const measurementId = body.measurement_id || ""; + const clientName = body.client_name || ""; + const clientTgId = body.client_tg_id || ""; + const id = generateId(); + const now = new Date(); + + // Pre-create lead row + appendRow("Leads", [ + id, now, tgId, clientTgId, clientName, measurementId, + JSON.stringify(checklist), "", "", 0, false, "new", 0 + ]); + + // Build prompt and call Claude + const measurement = measurementId ? getMeasurement(measurementId) : null; + const prompt = buildPickerPrompt(checklist, measurement, clientName); + const ai = callClaude(prompt); + + // Update lead row with AI response + updateLeadAI(id, ai); + + // Send result to manager via bot + const summary = formatPodborForTelegram(ai, clientName); + sendTelegram(tgId, summary); + + log("podbor_completed", tgId, { id, tokens: ai.tokens, has_json: !!ai.json }); + return { ok: true, id, summary }; +} + +// ================================================================= +// 4. Sheet helpers +// ================================================================= + +function ss() { return SpreadsheetApp.getActiveSpreadsheet(); } +function sheet(name) { + const s = ss().getSheetByName(name); + if (!s) throw new Error("Sheet not found: " + name); + return s; +} +function appendRow(sheetName, row) { sheet(sheetName).appendRow(row); } + +function findUser(tgId) { + if (!tgId) return null; + const data = sheet("Users").getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(tgId)) { + return { + tg_id: data[i][0], + tg_username: data[i][1], + first_name: data[i][2], + last_name: data[i][3], + role: data[i][4], + full_name: ((data[i][2] || "") + " " + (data[i][3] || "")).trim() || data[i][1] || "", + }; + } + } + return null; +} + +function getOrCreateUser(tgUser, startParam) { + const tgId = tgUser.id; + const existing = findUser(tgId); + if (existing) { + updateColumnByKey("Users", "tg_id", tgId, "last_seen_at", new Date()); + return existing; + } + // Если пришли по invite-коду менеджера — клиент с привязкой + let role = "client"; + let inviteCode = ""; + if (startParam && startParam.indexOf("client_inv_") === 0) { + role = "client"; + inviteCode = startParam; + } + const now = new Date(); + appendRow("Users", [ + tgId, tgUser.username || "", tgUser.first_name || "", tgUser.last_name || "", + role, now, now, inviteCode, + ]); + log("user_registered", tgId, { role, startParam }); + return findUser(tgId); +} + +function getManagerProfile(tgId) { + const data = sheet("Managers").getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(tgId)) { + const isZov = !!data[i][6]; + const lastOrder = data[i][8]; + const activePeriod = parseInt(getSetting("ACTIVE_PERIOD_DAYS") || "90", 10); + const gracePeriod = parseInt(getSetting("GRACE_PERIOD_DAYS") || "14", 10); + let activeUntil = null; + let status = "lapsed"; + if (isZov) { + status = "active"; + } else if (lastOrder instanceof Date) { + activeUntil = new Date(lastOrder.getTime() + activePeriod * 86400000); + const grace = new Date(activeUntil.getTime() + gracePeriod * 86400000); + const now = new Date(); + if (now <= activeUntil) status = "active"; + else if (now <= grace) status = "grace"; + else status = "lapsed"; + } + return { + tg_id: data[i][0], + full_name: data[i][1], + email: data[i][2], + phone: data[i][3], + salon: data[i][4], + city: data[i][5], + is_zov_employee: isZov, + last_order_date: lastOrder, + active_until: activeUntil, + status, + invite_code: data[i][13], + }; + } + } + return null; +} + +function getClientProfile(tgId) { + const data = sheet("Clients").getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(tgId)) { + return { + tg_id: data[i][0], + full_name: data[i][1], + phone: data[i][2], + email: data[i][3], + address: data[i][4], + city: data[i][5], + budget_total: data[i][6], + manager_tg_id: data[i][7], + source: data[i][8], + last_measurement_id: data[i][9], + }; + } + } + return null; +} + +function getMeasurement(id) { + const data = sheet("Measurements").getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (data[i][0] === id) { + return { + id, layout: data[i][5], area_m2: data[i][6], ceiling_mm: data[i][7], + walls: safeParse(data[i][8]), + openings: safeParse(data[i][9]), + infra: safeParse(data[i][10]), + niches: safeParse(data[i][11]), + }; + } + } + return null; +} + +function safeParse(s) { try { return JSON.parse(s || "{}"); } catch (e) { return {}; } } + +function updateColumnByKey(sheetName, keyCol, keyVal, targetCol, newVal) { + const s = sheet(sheetName); + const headers = s.getRange(1, 1, 1, s.getLastColumn()).getValues()[0]; + const keyIdx = headers.indexOf(keyCol); + const targetIdx = headers.indexOf(targetCol); + if (keyIdx < 0 || targetIdx < 0) return false; + const data = s.getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][keyIdx]) === String(keyVal)) { + s.getRange(i + 1, targetIdx + 1).setValue(newVal); + return true; + } + } + return false; +} + +function updateLeadAI(leadId, ai) { + const s = sheet("Leads"); + const data = s.getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (data[i][0] === leadId) { + const props = PropertiesService.getScriptProperties(); + const model = props.getProperty("ANTHROPIC_MODEL") || "claude-haiku-4-5-20251001"; + s.getRange(i + 1, 8).setValue(JSON.stringify(ai.json || ai.text || "")); + s.getRange(i + 1, 9).setValue(model); + s.getRange(i + 1, 10).setValue(ai.tokens || 0); + s.getRange(i + 1, 11).setValue(true); + return; + } + } +} + +function getSetting(key) { + const data = sheet("Settings").getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(key)) return String(data[i][1]); + } + return null; +} + +// Если менеджер ещё не заведён в Managers — синтезируем «болванку» с lapsed-статусом +function synthesizeManagerFromUser(user) { + return { + full_name: user.full_name, + salon: "", + is_zov_employee: false, + status: "lapsed", + active_until: null, + }; +} + +// ================================================================= +// 5. Claude AI +// ================================================================= + +const SYSTEM_PROMPT_PICKER = ( + "Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n" + + "Помогаешь менеджерам салонов быстро согласовать с клиентом комплект техники.\n\n" + + "Принципы:\n" + + "1. Физические ограничения важнее эстетики. Если ниша 600×1850×600 — не предлагай 700×2000×650.\n" + + "2. Уважай бюджет. Лимит в категории — не превышай >10%.\n" + + "3. Уважай предпочтения по брендам: сначала preferred (★), потом alternative (✓).\n" + + "4. Сценарий использования. Семья с детьми = простой UI, защита от детей. Выпечка = пар + конвекция.\n" + + "5. Инфраструктура. Газ исключает индукцию. Нет шахты = только рециркуляция.\n" + + "6. По каждой позиции: модель (линейка), цена, 2-3 преимущества под клиента, 1 предупреждение.\n\n" + + "Формат ответа — валидный JSON без markdown:\n" + + "{\n" + + ' "summary": "...", \n' + + ' "items": [{"category":"fridge","brand":"Bosch","model":"Serie 4 60см","price_rub":79990,' + + '"size_mm":{"w":600,"h":2030,"d":660},"fits_niche":true,"highlights":["NoFrost","инвертор"],' + + '"caveats":"Глубина 660мм","match_score":0.92}],\n' + + ' "total_price_rub": 350000,\n' + + ' "budget_status": "в_рамках|превышение|значительно_ниже",\n' + + ' "warnings": [],\n' + + ' "next_steps": []\n' + + "}\n\n" + + "Не выдумывай несуществующие артикулы — указывай линейку (Bosch Serie 4 60см)." +); + +function buildPickerPrompt(checklist, measurement, clientName) { + const payload = { + client: { name: clientName || "" }, + checklist: checklist, + measurement: measurement || null, + }; + return "Подбери технику для следующего клиента:\n\n" + JSON.stringify(payload, null, 2); +} + +function callClaude(userPrompt) { + const props = PropertiesService.getScriptProperties(); + const apiKey = props.getProperty("ANTHROPIC_API_KEY"); + if (!apiKey) throw new Error("ANTHROPIC_API_KEY not in Script Properties"); + const model = props.getProperty("ANTHROPIC_MODEL") || "claude-haiku-4-5-20251001"; + const temperature = parseFloat(getSetting("AI_TEMPERATURE") || "0.3"); + + const payload = { + model, + max_tokens: 4000, + temperature, + system: SYSTEM_PROMPT_PICKER, + messages: [{ role: "user", content: userPrompt }], + }; + + const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", { + method: "post", + contentType: "application/json", + headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" }, + payload: JSON.stringify(payload), + muteHttpExceptions: true, + }); + + const status = res.getResponseCode(); + const text = res.getContentText(); + if (status >= 400) { + log("claude_error", null, { status, text: text.slice(0, 500) }); + return { json: null, text: "AI ошибка: HTTP " + status, tokens: 0, error: true }; + } + const data = JSON.parse(text); + const responseText = (data.content || []).map(c => c.text || "").join(""); + const tokens = (data.usage && (data.usage.input_tokens + data.usage.output_tokens)) || 0; + let json = null; + try { json = JSON.parse(responseText); } catch (e) {} + return { json, text: responseText, tokens }; +} + +function formatPodborForTelegram(ai, clientName) { + if (ai.error) return "❌ Не удалось получить подбор от AI. Попробуйте позже."; + if (!ai.json) return "Подбор готов\n\n" + (ai.text || "").slice(0, 3500); + + const j = ai.json; + const lines = [ + "✅ Подбор готов", + clientName ? "Клиент: " + clientName + "" : "", + "", + j.summary || "", + "", + ]; + (j.items || []).forEach(item => { + const sizeStr = item.size_mm ? " (" + item.size_mm.w + "×" + item.size_mm.h + "×" + item.size_mm.d + "мм)" : ""; + lines.push("" + (item.brand || "") + " " + (item.model || "") + "" + sizeStr); + if (item.price_rub) lines.push("💰 " + formatPrice(item.price_rub) + " ₽"); + if (item.highlights && item.highlights.length) lines.push("✓ " + item.highlights.join(", ")); + if (item.caveats) lines.push("⚠️ " + item.caveats); + lines.push(""); + }); + if (j.total_price_rub) lines.push("ИТОГО: " + formatPrice(j.total_price_rub) + " ₽ · " + (j.budget_status || "")); + if (j.warnings && j.warnings.length) lines.push("\n⚠️ " + j.warnings.join("; ")); + return lines.join("\n"); +} + +function formatPrice(n) { + if (n === null || n === undefined || n === "") return "—"; + return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); +} + +// ================================================================= +// 6. Telegram Bot API +// ================================================================= + +function sendTelegram(chatId, text, options) { + const props = PropertiesService.getScriptProperties(); + const botToken = props.getProperty("BOT_TOKEN"); + if (!botToken || !chatId) return; + const url = "https://api.telegram.org/bot" + botToken + "/sendMessage"; + const payload = { + chat_id: chatId, + text, + parse_mode: "HTML", + disable_web_page_preview: true, + }; + if (options) Object.keys(options).forEach(k => payload[k] = options[k]); + try { + UrlFetchApp.fetch(url, { + method: "post", + contentType: "application/json", + payload: JSON.stringify(payload), + muteHttpExceptions: true, + }); + } catch (e) { + log("telegram_send_error", chatId, { error: String(e) }); + } +} + +// ================================================================= +// 7. Util +// ================================================================= + +function generateId() { return Utilities.getUuid().slice(0, 13); } + +function formatDate(d) { + if (!(d instanceof Date)) return ""; + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + return dd + "." + mm + "." + d.getFullYear(); +} + +function getInitial(name) { + return ((name || "").trim().slice(0, 1) || "?").toUpperCase(); +} + +function log(event, tgId, payload) { + try { + appendRow("Logs", [new Date(), event, tgId || "", payload ? JSON.stringify(payload) : ""]); + } catch (e) {} +} + +// ================================================================= +// 8. Утилиты для одноразового запуска (через Apps Script Run) +// ================================================================= + +/** + * Заводит Руслана (admin) как ZOV-employee менеджера. + * Запустить ОДИН РАЗ после деплоя через Run в редакторе. + */ +function seedAdminAsManager() { + const adminId = 5937498515; + const data = sheet("Managers").getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(adminId)) { + Logger.log("Уже заведён, ничего не делаем."); + return; + } + } + appendRow("Managers", [ + adminId, // tg_id + "Руслан Васильев", // full_name + "vasrusgen@gmail.com", // email + "", // phone + "ЗОВ — куратор сети", // salon + "Санкт-Петербург", // city + true, // is_zov_employee → всегда active + "active", // status (стат-поле; реальный считается на лету) + "", // last_order_date + "", // active_until + 0, 0, 0, // total_leads, total_deals, conversion + "MGR_ADMIN", // invite_code + ]); + // Также заводим в Users с ролью manager, если ещё нет + const u = findUser(adminId); + if (!u) { + appendRow("Users", [adminId, "VASRUSGEN", "Руслан", "Васильев", "manager", new Date(), new Date(), ""]); + } else if (u.role !== "manager") { + updateColumnByKey("Users", "tg_id", adminId, "role", "manager"); + } + Logger.log("✅ Руслан Васильев заведён как ZOV-employee менеджер."); +} + +/** + * Удобный тестовый прогон Claude API: проверяет что ключ работает. + */ +function testClaude() { + const ai = callClaude("Скажи одной фразой: что за фабрика ЗОВ?"); + Logger.log(JSON.stringify(ai, null, 2)); +} + +/** + * Тест отправки сообщения через бота — пришлёт админу «привет». + */ +function testTelegram() { + const props = PropertiesService.getScriptProperties(); + const adminId = props.getProperty("ADMIN_TG_ID") || 5937498515; + sendTelegram(adminId, "🟢 Привет из Apps Script бэкенда! Если видишь — связка бот↔backend работает."); } diff --git a/backend/README.md b/backend/README.md index 8f6b781..fda01fa 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,37 +1,72 @@ # Backend (Google Apps Script) -Этот код — зеркало того, что лежит в Apps Script-проекте, привязанном к Google Sheet «ЗОВ — База». +Это код, который живёт в Apps Script-проекте, привязанном к Google Sheet «ЗОВ — База». -## Как синхронизировать +## Файлы -Вариант 1 (вручную): копировать содержимое `Code.gs` в редактор Apps Script. +| Файл | Когда запускать | +|---|---| +| `setup_database.gs` | **один раз**, чтобы создать 8 листов (уже выполнено) | +| `Code.gs` | основной backend, обслуживает MiniApp и бот через `doPost` | -Вариант 2 (через clasp): использовать [clasp](https://github.com/google/clasp). +## Шаги после первой установки `Code.gs` -```bash -npm install -g @google/clasp -clasp login -clasp clone -# или для уже существующего: -clasp pull / clasp push -``` +### 1. Добавить Script Properties (секреты) -## Деплой - -1. Открыть Apps Script проект, привязанный к Sheet. -2. Deploy → New deployment. -3. Type: **Web app**. -4. Execute as: **Me**. -5. Who has access: **Anyone** (только так MiniApp сможет POST'ить). -6. Скопировать выданный URL — это `BACKEND_URL` в `miniapp/assets/app.js`. - -## Script Properties (секреты) - -В Apps Script: ⚙️ Project Settings → Script Properties → Add property. +В Apps Script: **⚙️ Project Settings** → секция **Script Properties** → **Add property**. | Key | Value | |---|---| -| `BOT_TOKEN` | токен @BotFather | -| `ANTHROPIC_API_KEY` | ключ Anthropic Console | -| `ADMIN_TG_ID` | tg_id куратора | -| `SHEET_ID` | ID Google Sheet (из URL) | +| `BOT_TOKEN` | токен Telegram-бота (из @BotFather) | +| `ANTHROPIC_API_KEY` | `sk-ant-api03-…` из console.anthropic.com | +| `ADMIN_TG_ID` | `5937498515` (ваш tg_id) | +| `ANTHROPIC_MODEL` | (опционально) `claude-haiku-4-5-20251001` | + +### 2. Запустить разовые setup-функции + +В верхней панели селект функций → выбрать → **▶ Run**: + +- **`seedAdminAsManager`** — заведёт Руслана Васильева как admin-менеджера в Managers/Users (статус всегда active как ZOV-employee). +- **`testClaude`** — проверит, что Anthropic API ключ работает (увидите ответ AI в Execution log). +- **`testTelegram`** — проверит связку: бот должен прислать вам "🟢 Привет из Apps Script бэкенда". + +### 3. Деплой как Web App + +В Apps Script: **Deploy** → **New deployment** → шестерёнка **Type → Web app**. + +Параметры: +- **Description:** `zov-tech-backend v1` +- **Execute as:** `Me (vasrusgen@gmail.com)` +- **Who has access:** `Anyone` (чтобы MiniApp мог POST'ить, ОБЯЗАТЕЛЬНО) + +Жмём **Deploy**. Получим URL вида: +``` +https://script.google.com/macros/s/AKfycbz.../exec +``` +Этот URL — `BACKEND_URL` для MiniApp. + +### 4. Прислать URL разработчику + +После деплоя URL подставляется в `miniapp/assets/app.js`, MiniApp начинает реально читать профиль из Sheet вместо мок-данных. + +## API endpoints + +Все запросы — `POST {BACKEND_URL}?path=` с JSON-телом `{ initData, ... }`. + +| Endpoint | Body | Ответ | +|---|---|---| +| `?path=ping` | `{}` | `{ pong: true, time }` (для health-check) | +| `?path=me` | `{ initData, startParam? }` | профиль пользователя (роль, имя, статус, менеджер) | +| `?path=measurement` | `{ initData, measurement: { layout, area_m2, ... } }` | `{ ok: true, id }` | +| `?path=podbor` | `{ initData, checklist, measurement_id?, client_name? }` | `{ ok: true, id, summary }` + AI-ответ в Telegram | + +## Безопасность + +- Все запросы (кроме `ping`) обязательно содержат `initData` от Telegram WebApp. +- Backend проверяет HMAC-SHA-256 подпись `initData` по `BOT_TOKEN`. +- При невалидной подписи → `{ error: "invalid_init_data" }`. +- 24-часовая свежесть подписи (`auth_date`). + +## Обновление + +Изменили `Code.gs` локально → скопировать в Apps Script → **Save** → **Deploy → Manage deployments → Edit → Version: New version → Deploy**. URL не меняется.