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 не меняется.