/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ KOBIA System — Script Maestro v5.7 NO setNumberFormat ║
* ║ Proyecto standalone en Google Drive ║
* ║ Un solo script para TODOS los clientes ║
* ║ ║
* ║ CAMBIOS EN v5.7: ║
* ║ • CRÍTICO: Eliminado setNumberFormat completamente ║
* ║ • Google Sheets auto-detecta el tipo de dato ║
* ║ • Simplifica guardado y elimina errores de formato ║
* ║ ║
* ║ CAMBIOS EN v5.6: ║
* ║ • Fix setNumberFormat en campos numéricos ║
* ║ • Solo aplicaba formato texto en campos NO numéricos ║
* ╚══════════════════════════════════════════════════════════════════════╝
*/
// ── Configuración global ─────────────────────────────────────────────
const CFG = {
SHEET_PRODUCTOS: 'Productos',
SHEET_CONFIG: 'Config',
SHEET_MASTER: 'Clientes',
ID_HOJA_MAESTRA: '1G30mdmJSpW5gzJTenjhHizjpB_iRfN7PkWDusJ3yrP8',
ID_PUBLIC_DATA: '1uY80XwQ9Y4_uxh10iXfAHKxuA6orVIIpvAsaa7b0Spw',
ID_PLANTILLA: '134WAp1lJzsZ3FiHuFqeUyma9CWbKQkZliWejAB7XQAU',
ID_CARPETA_RAIZ: '1bqYdXug2jWrADsKgzGJDlj23RLQD2yPZ', // ← ID fijo, nunca cambia
CARPETA_CLIENTES: 'Clientes', // ← subcarpeta dentro de la raíz
// Credenciales de administrador KOBIA
ADMIN_USER: 'kobia_admin',
ADMIN_PASS: 'kobia2026',
};
// ── Encabezados definitivos de la hoja Productos ─────────────────────
const HDRS_PRODUCTOS = [
'id', 'nombre', 'descripcion', 'precio', 'precio_oferta',
'categoria', 'subcategoria', 'imagen', 'stock',
'destacado', 'estado', 'etiqueta', 'variante'
];
// ── Encabezados definitivos de la hoja Config ────────────────────────
const HDRS_CONFIG = [
'propiedad', 'valor'
];
// ── Campos Config definitivos ────────────────────────────────────────
const CAMPOS_CONFIG = [
'nombre_marca', 'whatsapp',
'color_botones', 'color_fondos', 'color_detalles', 'color_titulos',
'color_nombre_producto', 'banner_portada', 'foto_perfil', 'frase_bienvenida',
'aviso_promocion', 'direccion_fisica', 'texto_pedido',
'instagram', 'tiktok', 'facebook', 'telefono',
'columnas_escritorio', 'columnas_movil',
'categorias', 'link_portafolio', 'tipo_plantilla', 'email'
];
// ============================================
// ============================================
// GENERACIÓN DE SLUG
// ============================================
function generateSlug(nombreMarca) {
if (!nombreMarca) {
throw new Error('Nombre de marca requerido para generar slug');
}
return nombreMarca
.toLowerCase()
.trim()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/ñ/g, 'n')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50)
.replace(/-+$/, '');
}
function validateUniqueSlug(slug, masterSheetId) {
const ss = SpreadsheetApp.openById(masterSheetId);
const clientsSheet = ss.getSheetByName('Clientes');
if (!clientsSheet) {
throw new Error('Hoja Clientes no encontrada');
}
const data = clientsSheet.getDataRange().getValues();
const headers = data[0].map(h => h.toString().toLowerCase());
const slugIndex = headers.indexOf('slug');
if (slugIndex === -1) {
Logger.log('⚠️ Columna SLUG no encontrada - se asume que no hay slugs existentes');
return slug;
}
const existingSlugs = data.slice(1)
.map(row => row[slugIndex]?.toString().toLowerCase().trim())
.filter(s => s);
let finalSlug = slug;
let counter = 1;
while (existingSlugs.includes(finalSlug.toLowerCase())) {
finalSlug = `${slug}-${counter}`;
counter++;
}
Logger.log('✅ Slug validado: ' + finalSlug);
return finalSlug;
}
// ============================================
// EMAIL DE BIENVENIDA (HTML)
// ============================================
function sendWelcomeEmail(clientData) {
const { nombreMarca, email, whatsapp, idKobia, slug } = clientData;
const subject = `¡Bienvenido a KOBIA, ${nombreMarca}!`;
const htmlBody = `
¡Bienvenido a KOBIA, ${nombreMarca}!
Tu catálogo digital profesional está listo para usar. Compártelo con tus clientes y empieza a vender hoy mismo.
👤 Panel de administración
https://kobia.com.co/catalogo/panel.html
Usuario: ${whatsapp}
Contraseña: ${idKobia}
Desde aquí puedes configurar tu catálogo, agregar productos, cambiar colores y mucho más.
💡 Próximos pasos:
- Entra a tu panel de administración
- Personaliza los colores de tu marca
- Agrega tus productos
- ¡Comparte tu catálogo y empieza a vender!
`;
try {
GmailApp.sendEmail(email, subject, "", {
name: "KOBIA",
from: "soporte@kobia.com.co",
htmlBody: htmlBody
});
Logger.log('✅ Email enviado a: ' + email);
} catch (error) {
Logger.log('❌ Error enviando email: ' + error);
}
}
// ============================================
// EMAIL DE VALIDACIÓN DE LEAD PENDIENTE (HTML)
// ============================================
function sendLeadValidationEmail(email, nombreMarca) {
const subject = `¡Solicitud Recibida! Preparando tu Catálogo KOBIA para ${nombreMarca}`;
const htmlBody = `
KOBIA
¡Hola! Hemos recibido con éxito tu solicitud para ${nombreMarca}
Gracias por elegir a KOBIA para potenciar tus ventas por WhatsApp. Hemos registrado tu pedido en nuestro sistema central.
¿Qué sigue ahora?
Tu membresía y tu Panel de Administrador privado se encuentran en estado Pendiente de Activación.
En el transcurso del día, un asesor de KOBIA se pondrá en contacto contigo a tu WhatsApp para confirmar los últimos detalles, brindarte asesoría y enviarte tus contraseñas de acceso oficiales.
Prepárate para llevar tu negocio a otro nivel.
Nos hablamos pronto.
`;
try {
GmailApp.sendEmail(email, subject, "", {
name: "KOBIA",
from: "soporte@kobia.com.co",
htmlBody: htmlBody
});
Logger.log('✅ Email validación Lead enviado a: ' + email);
} catch (error) {
Logger.log('❌ Error enviando email validación Lead: ' + error);
}
}
// ══════════════════════════════════════════════════════════════════════
// PUNTO DE ENTRADA — GET
// ══════════════════════════════════════════════════════════════════════
function doGet(e) {
const params = (e && e.parameter) || {};
const action = params.action || 'check';
const sheetId = params.sheetId || '';
try {
switch (action) {
case 'check': {
if (!sheetId) return R({ ok: false, error: 'Falta sheetId' });
const ss = SpreadsheetApp.openById(sheetId);
const sheet = ss.getSheetByName(CFG.SHEET_PRODUCTOS);
return R({
ok: true,
nombre: ss.getName(),
productos: sheet ? Math.max(0, sheet.getLastRow() - 1) : 0
});
}
default:
return R({ ok: false, error: 'Acción GET no reconocida: ' + action });
}
} catch (err) {
Logger.log('ERROR doGet [' + action + ']: ' + err.message);
return R({ ok: false, error: err.message });
}
}
// ══════════════════════════════════════════════════════════════════════
// PUNTO DE ENTRADA — POST (VERSIÓN SUPER-DEFENSIVA v5.4.1)
// ══════════════════════════════════════════════════════════════════════
function doPost(e) {
// ═══ LOG 1: Verificar que la función se ejecuta ═══════════════════
Logger.log('═══════════════════════════════════════════════════');
Logger.log('[KOBIA v5.4.1] ⚡ doPost() INICIADO');
Logger.log('[KOBIA] Timestamp: ' + new Date().toISOString());
Logger.log('═══════════════════════════════════════════════════');
try {
// ═══ LOG 2: Verificar que e existe ═══════════════════════════════
if (!e) {
Logger.log('[KOBIA] ❌ ERROR: Parámetro "e" es null o undefined');
return R({ ok: false, error: 'Parámetro de request vacío' });
}
Logger.log('[KOBIA] ✅ Parámetro "e" existe');
// ═══ LOG 3: Verificar que e.postData existe ═══════════════════════
if (!e.postData) {
Logger.log('[KOBIA] ❌ ERROR: e.postData es null o undefined');
return R({ ok: false, error: 'No hay datos POST' });
}
Logger.log('[KOBIA] ✅ e.postData existe');
// ═══ LOG 4: Verificar que e.postData.contents existe ═════════════
if (!e.postData.contents) {
Logger.log('[KOBIA] ❌ ERROR: e.postData.contents es null o undefined');
return R({ ok: false, error: 'POST sin contenido' });
}
const rawData = e.postData.contents;
Logger.log('[KOBIA] ✅ Raw data recibido');
Logger.log('[KOBIA] 📦 Tamaño: ' + rawData.length + ' bytes');
Logger.log('[KOBIA] 📝 Primeros 200 chars: ' + rawData.substring(0, 200));
// ═══ LOG 5: Intentar parsear JSON ════════════════════════════════
let data;
try {
data = JSON.parse(rawData);
Logger.log('[KOBIA] ✅ JSON parseado correctamente');
} catch(parseErr) {
Logger.log('[KOBIA] ❌ ERROR parseando JSON: ' + parseErr.message);
Logger.log('[KOBIA] 🔍 Raw data completo: ' + rawData);
return R({ ok: false, error: 'JSON inválido: ' + parseErr.message });
}
// ═══ LOG 6: Verificar action ═════════════════════════════════════
const action = data.action || '';
Logger.log('[KOBIA] 📋 Action recibida: ' + (action || '(vacía)'));
Logger.log('[KOBIA] 📦 Keys en payload: ' + Object.keys(data).join(', '));
if (!action) {
Logger.log('[KOBIA] ⚠️ ADVERTENCIA: Action está vacía');
}
// Acciones del Panel Master autorizadas per se
const MASTER_ACTIONS = ['clonar', 'createClient', 'suspender', 'activar', 'registrarPreCliente', 'listarClientes'];
// ═══ LOG 7: Verificar tipo de acción ═════════════════════════════
const esMasterAction = MASTER_ACTIONS.includes(action);
Logger.log('[KOBIA] 🔍 Es acción Master: ' + (esMasterAction ? 'SÍ' : 'NO'));
// Si NO es una acción maestra, exigimos credenciales de auth
if (!esMasterAction) {
Logger.log('[KOBIA] 🔐 Requiere autenticación de cliente');
const whatsapp = data._auth_whatsapp || data.whatsapp || '';
const pin = data._auth_pin || data.pin || '';
Logger.log('[KOBIA] 📱 WhatsApp recibido: ' + (whatsapp || '(vacío)'));
Logger.log('[KOBIA] 🔑 PIN recibido: ' + (pin || '(vacío)'));
// ═══ LOG 8: Verificar credenciales ═══════════════════════════
if (!whatsapp || !pin) {
Logger.log('[KOBIA] ❌ ERROR: Faltan credenciales');
Logger.log('[KOBIA] WhatsApp: ' + (whatsapp ? 'presente' : 'FALTA'));
Logger.log('[KOBIA] PIN: ' + (pin ? 'presente' : 'FALTA'));
return R({ ok: false, error: 'Autenticación requerida: Envíe _auth_whatsapp y _auth_pin en el JSON.' });
}
Logger.log('[KOBIA] ✅ Credenciales presentes, iniciando autenticación...');
// ═══ LOG 9: Autenticar usuario ═══════════════════════════════
let sheetIdAutorizado;
try {
sheetIdAutorizado = autenticarUsuario(whatsapp, pin);
Logger.log('[KOBIA] 🔍 Resultado autenticación: ' + (sheetIdAutorizado || 'FALLO'));
} catch(authErr) {
Logger.log('[KOBIA] ❌ ERROR en autenticarUsuario(): ' + authErr.message);
Logger.log('[KOBIA] 🔍 Stack: ' + authErr.stack);
return R({ ok: false, error: 'Error de autenticación: ' + authErr.message });
}
if (!sheetIdAutorizado) {
Logger.log('[KOBIA] ❌ Autenticación FALLIDA - Credenciales inválidas');
return R({ ok: false, error: 'Credenciales inválidas (WhatsApp o PIN incorrecto)' });
}
Logger.log('[KOBIA] ✅ Autenticación EXITOSA');
Logger.log('[KOBIA] 🆔 SheetId autorizado: ' + sheetIdAutorizado);
// ═══ LOG 10: Ejecutar acción ═════════════════════════════════
Logger.log('[KOBIA] 🚀 Ejecutando acción: ' + action);
// Ejecutamos las modificaciones únicamente sobre la hoja autorizada
switch (action) {
case 'login':
Logger.log('[KOBIA] → Llamando loginPanel()');
return loginPanel(whatsapp, pin, sheetIdAutorizado);
case 'agregar':
Logger.log('[KOBIA] → Llamando agregarProducto()');
return agregarProducto(sheetIdAutorizado, data);
case 'editar':
Logger.log('[KOBIA] → Llamando editarProducto()');
return editarProducto(sheetIdAutorizado, data);
case 'desactivar':
Logger.log('[KOBIA] → Llamando cambiarEstadoProducto(inactivo)');
return cambiarEstadoProducto(sheetIdAutorizado, data.id, 'inactivo');
case 'activar_producto':
Logger.log('[KOBIA] → Llamando cambiarEstadoProducto(activo)');
return cambiarEstadoProducto(sheetIdAutorizado, data.id, 'activo');
case 'config':
Logger.log('[KOBIA] → Llamando guardarConfig()');
return guardarConfig(sheetIdAutorizado, data);
case 'diagnostico':
Logger.log('[KOBIA] → Llamando diagnosticarConfig()');
return diagnosticarConfig(sheetIdAutorizado, data);
default:
Logger.log('[KOBIA] ❌ Acción no reconocida: ' + action);
return R({ ok: false, error: 'Acción no reconocida: ' + action });
}
} else {
// ═══ ACCIONES DEL PANEL MASTER ═══════════════════════════════
Logger.log('[KOBIA] 👑 Ejecutando acción MASTER: ' + action);
switch (action) {
case 'clonar':
case 'createClient':
Logger.log('[KOBIA] → Llamando crearCliente()');
return crearCliente(data);
case 'suspender':
Logger.log('[KOBIA] → Llamando cambiarEstadoCliente(Suspendido)');
return cambiarEstadoCliente(data.idKobia, 'Suspendido');
case 'activar':
Logger.log('[KOBIA] → Llamando cambiarEstadoCliente(Activo)');
return cambiarEstadoCliente(data.idKobia, 'Activo');
case 'registrarPreCliente':
Logger.log('[KOBIA] → Llamando registrarLeadPendiente()');
return registrarLeadPendiente(data);
case 'listarClientes':
Logger.log('[KOBIA] → Llamando listarClientes()');
return listarClientes(data);
default:
Logger.log('[KOBIA] ❌ Acción MAESTRA no reconocida: ' + action);
return R({ ok: false, error: 'Acción MAESTRA no reconocida: ' + action });
}
}
} catch (err) {
Logger.log('═══════════════════════════════════════════════════');
Logger.log('[KOBIA] ❌❌❌ ERROR CRÍTICO EN doPost() ❌❌❌');
Logger.log('[KOBIA] Error: ' + err.message);
Logger.log('[KOBIA] Stack completo:');
Logger.log(err.stack);
Logger.log('═══════════════════════════════════════════════════');
return R({ ok: false, error: err.message });
}
}
// ══════════════════════════════════════════════════════════════════════
// SISTEMA CENTRAL DE AUTENTICACIÓN
// ══════════════════════════════════════════════════════════════════════
function autenticarUsuario(whatsappEnviado, pinEnviado) {
const wsMaster = SpreadsheetApp.openById(CFG.ID_HOJA_MAESTRA);
const hojaClientes = wsMaster.getSheetByName(CFG.SHEET_MASTER);
const data = hojaClientes.getDataRange().getValues();
Logger.log('[AUTH] Filas en hoja Clientes: ' + data.length);
Logger.log('[AUTH] Encabezados: ' + JSON.stringify(data[0]));
if (data.length < 2) return false;
const headers = data[0].map(h => String(h).toLowerCase().replace(/[\s_\-]+/g, ''));
Logger.log('[AUTH] Headers normalizados: ' + JSON.stringify(headers));
const whatsappColIdx = headers.findIndex(h => h === 'whatsappid' || h === 'whatsapp');
const sheetIdColIdx = headers.findIndex(h => h === 'idsheetcliente' || h === 'idsheet');
const idKobiaColIdx = 0;
Logger.log('[AUTH] Col whatsapp: ' + whatsappColIdx + ' | Col sheetId: ' + sheetIdColIdx);
if (whatsappColIdx === -1 || sheetIdColIdx === -1) {
throw new Error('Validación corrompida: Faltan las columnas base whatsapp_id o id_sheet_cliente');
}
const targetWhatsapp = String(whatsappEnviado).replace(/[^0-9]/g, '').slice(-10);
const targetPin = String(pinEnviado).trim();
Logger.log('[AUTH] Buscando whatsapp (10 dígitos): ' + targetWhatsapp + ' | pin: ' + targetPin);
for (let i = 1; i < data.length; i++) {
let dbWhatsapp = String(data[i][whatsappColIdx]).replace(/[^0-9]/g, '').slice(-10);
let dbPin = String(data[i][idKobiaColIdx]).trim();
Logger.log('[AUTH] Fila ' + i + ': whatsapp=' + dbWhatsapp + ' pin=' + dbPin);
if (dbWhatsapp === targetWhatsapp && dbPin === targetPin) {
Logger.log('[AUTH] ✅ Cliente encontrado. SheetId: ' + data[i][sheetIdColIdx]);
return String(data[i][sheetIdColIdx]).trim();
}
}
Logger.log('[AUTH] ❌ Cliente no encontrado');
return false;
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: CREAR CLIENTE (MASTER)
// Estructura Drive:
// [ID_CARPETA_RAIZ]/
// └── Clientes/
// └── YYMM-NNN-CAT-MARCA/ ← carpeta cliente
// ├── YYMM-NNN-CAT-MARCA ← Sheet
// └── YYMM-NNN-IMG/ ← imágenes
// ══════════════════════════════════════════════════════════════════════
function crearCliente(data) {
Logger.log('═══════════════════════════════════════════════════');
Logger.log('👤 CREANDO NUEVO CLIENTE');
Logger.log('═══════════════════════════════════════════════════');
Logger.log('📦 Datos recibidos desde master.html:');
Logger.log(' nombreMarca: ' + (data.nombreMarca || '(vacío)'));
Logger.log(' whatsappId: ' + (data.whatsappId || '(vacío)'));
Logger.log(' whatsapp: ' + (data.whatsapp || '(vacío)'));
Logger.log(' email: ' + (data.email || '(vacío)'));
Logger.log(' categoria: ' + (data.categoria || '(vacío)'));
Logger.log(' plan: ' + (data.plan || '(vacío)'));
Logger.log(' meses: ' + (data.meses || '(vacío)'));
Logger.log('═══════════════════════════════════════════════════');
const ss = SpreadsheetApp.openById(CFG.ID_HOJA_MAESTRA);
const clientsSheet = ss.getSheetByName(CFG.SHEET_MASTER);
if (!clientsSheet) throw new Error('Hoja Clientes no encontrada');
// ── 1. Generar ID_KOBIA (YYMM-NNN) ───────────────────────────────
const yymm = Utilities.formatDate(new Date(), 'GMT-5', 'yyMM');
const allIds = clientsSheet.getLastRow() > 1
? clientsSheet.getRange(2, 1, clientsSheet.getLastRow() - 1, 1).getValues()
.map(r => r[0].toString())
.filter(id => !id.startsWith('LEAD-') && id.includes('-'))
.map(id => parseInt(id.split('-').pop()) || 0)
: [];
const nextNum = allIds.length > 0 ? Math.max(...allIds) + 1 : 1;
const idKobia = `${yymm}-${String(nextNum).padStart(3, '0')}`;
const catUp = (data.categoria || 'VAR').toString().toUpperCase().trim();
const marcaUp = (data.nombreMarca || '').toString().toUpperCase().replace(/\s+/g, '-').trim();
// Nombres normalizados siguiendo sistema de codificación
const nombreCarpetaCliente = `${idKobia}-${catUp}-${marcaUp}`; // 2603-001-MOD-VALENTINA
const nombreSheet = nombreCarpetaCliente; // mismo nombre para el Sheet
const nombreCarpetaImg = `${idKobia}-IMG`; // 2603-001-IMG
Logger.log('🆔 ID_KOBIA: ' + idKobia);
Logger.log('📁 Carpeta cliente: ' + nombreCarpetaCliente);
Logger.log('🖼️ Carpeta imágenes: ' + nombreCarpetaImg);
// ── 2. Generar slug único ─────────────────────────────────────────
const baseSlug = generateSlug(data.nombreMarca);
const slug = validateUniqueSlug(baseSlug, CFG.ID_HOJA_MAESTRA);
Logger.log('🔗 SLUG: ' + slug);
// ── 3. Crear estructura de carpetas en Drive ──────────────────────
// Raíz fija por ID — nunca busca por nombre
const carpetaRaiz = DriveApp.getFolderById(CFG.ID_CARPETA_RAIZ);
// Subcarpeta "Clientes" dentro de la raíz
const iterClientes = carpetaRaiz.getFoldersByName(CFG.CARPETA_CLIENTES);
const carpetaClientes = iterClientes.hasNext()
? iterClientes.next()
: carpetaRaiz.createFolder(CFG.CARPETA_CLIENTES);
// Carpeta individual del cliente
const carpetaCliente = carpetaClientes.createFolder(nombreCarpetaCliente);
// Carpeta de imágenes con ID único
const carpetaImagenes = carpetaCliente.createFolder(nombreCarpetaImg);
Logger.log('✅ Estructura de carpetas creada');
// ── 4. Clonar plantilla dentro de la carpeta del cliente ──────────
const plantilla = DriveApp.getFileById(CFG.ID_PLANTILLA);
const nuevoSheet = plantilla.makeCopy(nombreSheet, carpetaCliente);
const newSheetId = nuevoSheet.getId();
// Permisos públicos de lectura (necesario para CSV)
try {
nuevoSheet.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
} catch(e) {
Logger.log('⚠️ No se pudieron configurar permisos: ' + e.message);
}
Logger.log('✅ Sheet clonado: ' + newSheetId);
// ── 5. Registrar en hoja Clientes (privada) ───────────────────────
const fechaInicio = new Date();
const meses = parseInt(data.meses) || 1;
const fechaFin = new Date(fechaInicio);
fechaFin.setMonth(fechaFin.getMonth() + meses);
const fmt = (d) => {
return String(d.getDate()).padStart(2,'0') + '/' +
String(d.getMonth()+1).padStart(2,'0') + '/' +
d.getFullYear();
};
const tipoPlan = (data.tipo_plantilla || data.tipoPlantilla || 'catalogo').toLowerCase().trim();
clientsSheet.appendRow([
idKobia, // A — ID_KOBIA
data.categoria, // B — CATEGORIA
data.nombreMarca, // C — NOMBRE_MARCA
data.whatsappId || data.whatsapp || '', // D — WHATSAPP_ID (campo de autenticación)
data.email, // E — EMAIL
newSheetId, // F — ID_SHEET_CLIENTE
'Activo', // G — ESTADO
data.plan || 'base', // H — PLAN
fmt(fechaInicio), // I — FECHA_INICIO
meses, // J — MESES
fmt(fechaFin), // K — FECHA_FIN
slug, // L — SLUG
tipoPlan // M — TIPO_PLANTILLA
]);
Logger.log('✅ Cliente registrado en hoja Clientes');
// ── 6. Escribir tipo_plantilla en Config del cliente nuevo ───────
// Esto activa el redirect automático en catalogo.html sin acción del admin
if (tipoPlan === 'portafolio') {
try {
const newSs = SpreadsheetApp.openById(newSheetId);
const cfgSheet = obtenerOCrearHoja(newSs, CFG.SHEET_CONFIG, HDRS_CONFIG);
const cfgData = cfgSheet.getDataRange().getValues();
const tipoExiste = cfgData.some(row => norm(String(row[0])) === 'tipoplantilla');
if (!tipoExiste) {
cfgSheet.appendRow(['tipo_plantilla', 'portafolio']);
Logger.log('✅ tipo_plantilla=portafolio escrito en Config del cliente');
}
} catch(e) {
Logger.log('⚠️ No se pudo escribir tipo_plantilla en Config: ' + e.message);
}
}
// ── 7. Eliminar lead temporal si viene de conversión ─────────────
if (data.leadIdToDelete) {
const filas = clientsSheet.getDataRange().getValues();
const rowIdx = filas.findIndex((r, i) => i > 0 && r[0] === data.leadIdToDelete);
if (rowIdx >= 0) {
clientsSheet.deleteRow(rowIdx + 1);
Logger.log('🗑️ Lead temporal eliminado: ' + data.leadIdToDelete);
}
}
// ── 8. Registrar slug en archivo público de Slugs ─────────────────
try {
const publicSs = SpreadsheetApp.openById(CFG.ID_PUBLIC_DATA);
let slugsSheet = publicSs.getSheetByName('Slugs');
if (!slugsSheet) {
slugsSheet = publicSs.insertSheet('Slugs');
slugsSheet.appendRow(['slug', 'id_sheet']);
}
slugsSheet.appendRow([slug, newSheetId]);
Logger.log('✅ Slug registrado en archivo público');
} catch(e) {
Logger.log('⚠️ Error registrando slug público: ' + e.message);
}
// ── 9. Email de bienvenida ────────────────────────────────────────
const whatsappParaEmail = data.whatsappId || data.whatsapp || '';
Logger.log('📧 Preparando email de bienvenida...');
Logger.log(' Destinatario: ' + data.email);
Logger.log(' WHATSAPP_ID (usuario): ' + whatsappParaEmail);
Logger.log(' ID_KOBIA (contraseña): ' + idKobia);
Logger.log(' Slug: ' + slug);
sendWelcomeEmail({
nombreMarca: data.nombreMarca,
email: data.email,
whatsapp: whatsappParaEmail, // WHATSAPP_ID (usuario de autenticación)
idKobia: idKobia,
slug: slug
});
Logger.log('✅ Email de bienvenida enviado');
Logger.log('✅ Cliente creado exitosamente: ' + idKobia);
return R({
ok: true,
idKobia: idKobia,
sheetId: newSheetId,
slug: slug,
nombreCarpeta: nombreCarpetaCliente,
nombreCarpetaImg: nombreCarpetaImg,
catalogUrl: `https://kobia.com.co/${slug}`,
panelUrl: 'https://kobia.com.co/catalogo/panel.html'
});
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: AGREGAR PRODUCTO
// ══════════════════════════════════════════════════════════════════════
function agregarProducto(sheetId, data) {
const ss = SpreadsheetApp.openById(sheetId);
const sheet = obtenerOCrearHoja(ss, CFG.SHEET_PRODUCTOS, HDRS_PRODUCTOS);
const lastRow = sheet.getLastRow();
let nextId = 1;
if (lastRow >= 2) {
const ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues()
.map(r => parseInt(r[0]) || 0);
nextId = Math.max(...ids) + 1;
}
let imageUrl = data.imagenUrl || '';
if (data.imagen && data.imagen.indexOf('base64,') > -1) {
const carpeta = obtenerCarpetaCliente(sheetId);
imageUrl = subirImagen(data.imagen, data.imagenTipo, 'prod_' + nextId, carpeta);
}
const fila = construirFila(HDRS_PRODUCTOS, data, imageUrl, nextId, 'activo');
sheet.appendRow(fila);
Logger.log('[KOBIA] Producto agregado — sheet:' + sheetId + ' id:' + nextId);
return R({ ok: true, id: nextId, imageUrl });
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: EDITAR PRODUCTO
// ══════════════════════════════════════════════════════════════════════
function editarProducto(sheetId, data) {
const ss = SpreadsheetApp.openById(sheetId);
const sheet = ss.getSheetByName(CFG.SHEET_PRODUCTOS);
if (!sheet) throw new Error('No existe la pestaña Productos');
const id = parseInt(data.id);
if (!id) throw new Error('Se requiere el id del producto');
const valores = sheet.getDataRange().getValues();
const rowIndex = valores.findIndex((row, i) => i > 0 && parseInt(row[0]) === id);
if (rowIndex < 0) throw new Error('Producto no encontrado: id=' + id);
const hdrsNorm = valores[0].map(norm);
const idxImg = hdrsNorm.indexOf('imagen');
let imageUrl = data.imagenUrl || (idxImg >= 0 ? valores[rowIndex][idxImg] : '') || '';
if (data.imagen && data.imagen.indexOf('base64,') > -1) {
const carpeta = obtenerCarpetaCliente(sheetId);
imageUrl = subirImagen(data.imagen, data.imagenTipo, 'prod_' + id, carpeta);
}
const fila = construirFila(valores[0], data, imageUrl, id, data.estado || 'activo');
sheet.getRange(rowIndex + 1, 1, 1, fila.length).setValues([fila]);
Logger.log('[KOBIA] Producto editado — sheet:' + sheetId + ' id:' + id);
return R({ ok: true, id, imageUrl });
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: CAMBIAR ESTADO PRODUCTO (activo / inactivo)
// ══════════════════════════════════════════════════════════════════════
function cambiarEstadoProducto(sheetId, id, estado) {
const ss = SpreadsheetApp.openById(sheetId);
const sheet = ss.getSheetByName(CFG.SHEET_PRODUCTOS);
if (!sheet) throw new Error('No existe la pestaña Productos');
const idNum = parseInt(id);
const valores = sheet.getDataRange().getValues();
const hdrs = valores[0].map(norm);
const idxEstado = hdrs.indexOf('estado');
const rowIndex = valores.findIndex((row, i) => i > 0 && parseInt(row[0]) === idNum);
if (rowIndex < 0) throw new Error('Producto no encontrado: id=' + id);
if (idxEstado < 0) {
const newCol = sheet.getLastColumn() + 1;
sheet.getRange(1, newCol).setValue('estado');
sheet.getRange(rowIndex + 1, newCol).setValue(estado);
} else {
sheet.getRange(rowIndex + 1, idxEstado + 1).setValue(estado);
}
Logger.log('[KOBIA] Estado producto ' + id + ' → ' + estado);
return R({ ok: true, id: idNum, estado });
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: DIAGNOSTICAR CONFIG (v5.4 DEBUG)
// ══════════════════════════════════════════════════════════════════════
function diagnosticarConfig(sheetId, data) {
const resultado = {
timestamp: new Date().toISOString(),
sheetId: sheetId,
diagnostico: {}
};
Logger.log('═══════════════════════════════════════════════════');
Logger.log('[DIAGNÓSTICO] Iniciando diagnóstico completo');
Logger.log('═══════════════════════════════════════════════════');
try {
// 1. Verificar Sheet
resultado.diagnostico.sheet = {
id: sheetId,
existe: false,
accesible: false
};
try {
const ss = SpreadsheetApp.openById(sheetId);
resultado.diagnostico.sheet.existe = true;
resultado.diagnostico.sheet.nombre = ss.getName();
resultado.diagnostico.sheet.accesible = true;
Logger.log('[DIAGNÓSTICO] ✅ Sheet accesible: ' + ss.getName());
} catch(e) {
resultado.diagnostico.sheet.error = e.message;
Logger.log('[DIAGNÓSTICO] ❌ Error abriendo Sheet: ' + e.message);
return R(resultado);
}
// 2. Verificar hoja Config
resultado.diagnostico.config = {
hojaExiste: false,
headers: [],
filas: 0
};
try {
const ss = SpreadsheetApp.openById(sheetId);
const sheet = ss.getSheetByName(CFG.SHEET_CONFIG);
if (!sheet) {
resultado.diagnostico.config.error = 'Hoja Config no existe';
Logger.log('[DIAGNÓSTICO] ❌ Hoja Config no existe');
} else {
resultado.diagnostico.config.hojaExiste = true;
const valores = sheet.getDataRange().getValues();
resultado.diagnostico.config.filas = valores.length - 1;
if (valores.length > 0) {
resultado.diagnostico.config.headers = valores[0];
}
// Listar propiedades existentes
resultado.diagnostico.config.propiedadesExistentes = [];
for (let i = 1; i < Math.min(valores.length, 25); i++) { // Máximo 24 propiedades
if (valores[i][0]) {
resultado.diagnostico.config.propiedadesExistentes.push({
fila: i + 1,
propiedad: valores[i][0],
valor: valores[i][1] ? String(valores[i][1]).substring(0, 50) : '(vacío)'
});
}
}
Logger.log('[DIAGNÓSTICO] ✅ Hoja Config existe con ' + resultado.diagnostico.config.filas + ' filas');
}
} catch(e) {
resultado.diagnostico.config.error = e.message;
Logger.log('[DIAGNÓSTICO] ❌ Error leyendo Config: ' + e.message);
}
// 3. Verificar payload recibido
resultado.diagnostico.payload = {
camposRecibidos: Object.keys(data).filter(k => !k.startsWith('_') && k !== 'action'),
camposEsperados: CAMPOS_CONFIG,
camposFaltantes: [],
camposExtra: [],
valoresPorCampo: {}
};
// Campos faltantes
CAMPOS_CONFIG.forEach(campo => {
const valor = data[campo];
if (valor === undefined || valor === null || valor === '') {
resultado.diagnostico.payload.camposFaltantes.push(campo);
} else {
// Guardar primeros 50 caracteres del valor
resultado.diagnostico.payload.valoresPorCampo[campo] = String(valor).substring(0, 50);
}
});
// Campos extra (no esperados)
Object.keys(data).forEach(campo => {
if (!campo.startsWith('_') && campo !== 'action' && !CAMPOS_CONFIG.includes(campo)) {
resultado.diagnostico.payload.camposExtra.push(campo);
}
});
Logger.log('[DIAGNÓSTICO] 📦 Campos recibidos: ' + resultado.diagnostico.payload.camposRecibidos.length);
Logger.log('[DIAGNÓSTICO] ⏭️ Campos faltantes: ' + resultado.diagnostico.payload.camposFaltantes.length);
// 4. Verificar permisos de escritura
resultado.diagnostico.permisos = {
puedeEscribir: false
};
try {
const ss = SpreadsheetApp.openById(sheetId);
const sheet = ss.getSheetByName(CFG.SHEET_CONFIG);
if (sheet) {
// Intentar escribir en una celda temporal
const testRow = sheet.getLastRow() + 1;
sheet.getRange(testRow, 1).setValue('__TEST_KOBIA__');
// Verificar que se escribió
const testVal = sheet.getRange(testRow, 1).getValue();
if (testVal === '__TEST_KOBIA__') {
resultado.diagnostico.permisos.puedeEscribir = true;
// Limpiar celda de prueba
sheet.deleteRow(testRow);
Logger.log('[DIAGNÓSTICO] ✅ Permisos de escritura OK');
}
}
} catch(e) {
resultado.diagnostico.permisos.error = e.message;
Logger.log('[DIAGNÓSTICO] ❌ Error verificando permisos: ' + e.message);
}
// 5. Resumen
resultado.resumen = {
sheetAccesible: resultado.diagnostico.sheet.accesible,
configExiste: resultado.diagnostico.config.hojaExiste,
filasConfig: resultado.diagnostico.config.filas,
camposRecibidos: resultado.diagnostico.payload.camposRecibidos.length,
camposFaltantes: resultado.diagnostico.payload.camposFaltantes.length,
puedeEscribir: resultado.diagnostico.permisos.puedeEscribir,
diagnosticoCompleto: true
};
} catch(err) {
resultado.error = err.message;
resultado.stack = err.stack;
Logger.log('[DIAGNÓSTICO] ❌ Error general: ' + err.message);
}
Logger.log('═══════════════════════════════════════════════════');
Logger.log('[DIAGNÓSTICO] RESULTADO:');
Logger.log(JSON.stringify(resultado.resumen, null, 2));
Logger.log('═══════════════════════════════════════════════════');
return R(resultado);
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: GUARDAR CONFIG
// ══════════════════════════════════════════════════════════════════════
function guardarConfig(sheetId, data) {
Logger.log('═══════════════════════════════════════════════════');
Logger.log('[CONFIG v5.4] ⚙️ INICIANDO GUARDADO');
Logger.log('[CONFIG] 🆔 SheetId: ' + sheetId);
Logger.log('[CONFIG] 📦 Total campos en payload: ' + Object.keys(data).length);
Logger.log('[CONFIG] 📋 Campos recibidos: ' + Object.keys(data).filter(k => !k.startsWith('_')).join(', '));
Logger.log('═══════════════════════════════════════════════════');
// ── VALIDACIÓN 1: SheetId válido ──────────────────────────────
if (!sheetId || sheetId === 'null' || sheetId === 'undefined') {
Logger.log('[CONFIG] ❌ ERROR CRÍTICO: sheetId inválido: ' + sheetId);
throw new Error('SheetId inválido: ' + sheetId);
}
// ── VALIDACIÓN 2: Abrir Sheet ─────────────────────────────────
let ss;
try {
ss = SpreadsheetApp.openById(sheetId);
Logger.log('[CONFIG] ✅ Sheet abierto correctamente: ' + ss.getName());
} catch(e) {
Logger.log('[CONFIG] ❌ ERROR: No se pudo abrir el Sheet');
Logger.log('[CONFIG] 🔍 Error: ' + e.message);
throw new Error('No se pudo abrir Sheet ' + sheetId + ': ' + e.message);
}
// ── VALIDACIÓN 3: Obtener/crear hoja Config ───────────────────
let sheet;
try {
sheet = obtenerOCrearHoja(ss, CFG.SHEET_CONFIG, HDRS_CONFIG);
Logger.log('[CONFIG] ✅ Hoja Config obtenida/creada correctamente');
} catch(e) {
Logger.log('[CONFIG] ❌ ERROR: No se pudo obtener/crear hoja Config');
Logger.log('[CONFIG] 🔍 Error: ' + e.message);
throw new Error('Error obteniendo hoja Config: ' + e.message);
}
// ── ANÁLISIS: Estructura actual de la hoja ────────────────────
const valores = sheet.getDataRange().getValues();
Logger.log('[CONFIG] 📊 Filas totales en Config: ' + valores.length);
Logger.log('[CONFIG] 📊 Filas de datos (sin header): ' + (valores.length - 1));
if (valores.length > 0) {
Logger.log('[CONFIG] 📋 Headers encontrados: ' + valores[0].join(', '));
}
// Mapeo de propiedades existentes
const mapeo = {};
for (let i = 1; i < valores.length; i++) {
const prop = norm(valores[i][0]);
if (prop) {
mapeo[prop] = i + 1;
Logger.log('[CONFIG] 🗺️ Fila ' + (i + 1) + ': ' + valores[i][0] + ' (norm: ' + prop + ')');
}
}
Logger.log('[CONFIG] 📊 Total propiedades mapeadas: ' + Object.keys(mapeo).length);
// ── PROCESAMIENTO: Imágenes Base64 ────────────────────────────
Logger.log('[CONFIG] 🖼️ Verificando imágenes Base64...');
try {
const carpeta = obtenerCarpetaCliente(sheetId);
Logger.log('[CONFIG] 📁 Carpeta del cliente obtenida');
if (data.foto_perfilBase64) {
Logger.log('[CONFIG] 📸 Procesando foto_perfil...');
const urlLogo = subirImagen(data.foto_perfilBase64, data.foto_perfilTipo, 'logo', carpeta);
data.foto_perfil = urlLogo;
Logger.log('[CONFIG] ✅ foto_perfil subida: ' + urlLogo.substring(0, 80) + '...');
} else {
Logger.log('[CONFIG] ⏭️ No hay foto_perfilBase64 en payload');
}
if (data.banner_portadaBase64) {
Logger.log('[CONFIG] 🖼️ Procesando banner_portada...');
const urlBanner = subirImagen(data.banner_portadaBase64, data.banner_portadaTipo, 'banner', carpeta);
data.banner_portada = urlBanner;
Logger.log('[CONFIG] ✅ banner_portada subido: ' + urlBanner.substring(0, 80) + '...');
} else {
Logger.log('[CONFIG] ⏭️ No hay banner_portadaBase64 en payload');
}
} catch(e) {
Logger.log('[CONFIG] ⚠️ Error procesando imágenes: ' + e.message);
Logger.log('[CONFIG] 🔍 Stack: ' + e.stack);
}
// ── PROCESAMIENTO: Campos de configuración ────────────────────
Logger.log('[CONFIG] 📝 Iniciando guardado de campos...');
Logger.log('[CONFIG] 📋 Campos esperados (CAMPOS_CONFIG): ' + CAMPOS_CONFIG.length);
const CAMPOS_NUMERICOS = ['columnasescritorio', 'columnasmovil'];
let camposGuardados = 0;
let camposOmitidos = 0;
let camposCreados = 0;
let camposActualizados = 0;
let erroresGuardado = 0;
CAMPOS_CONFIG.forEach(campo => {
const valor = data[campo];
const campoNorm = norm(campo);
// Debug: mostrar cada campo procesado
Logger.log('[CONFIG] ─────────────────────────────────────────');
Logger.log('[CONFIG] 🔍 Procesando campo: ' + campo);
Logger.log('[CONFIG] Normalizado: ' + campoNorm);
Logger.log('[CONFIG] Valor recibido: ' + (valor !== undefined && valor !== null ? String(valor).substring(0, 100) : '(vacío)'));
Logger.log('[CONFIG] Tipo: ' + typeof valor);
if (valor === undefined || valor === null || valor === '') {
Logger.log('[CONFIG] ⏭️ OMITIDO (valor vacío)');
camposOmitidos++;
return;
}
let valorFinal;
if (CAMPOS_NUMERICOS.includes(campoNorm)) {
valorFinal = parseInt(valor) || 0;
Logger.log('[CONFIG] 🔢 Campo numérico: ' + valorFinal);
} else {
valorFinal = String(valor);
Logger.log('[CONFIG] 📝 Campo texto: ' + valorFinal.substring(0, 50) + (valorFinal.length > 50 ? '...' : ''));
}
try {
if (mapeo[campoNorm]) {
// ── Actualizar fila existente ──────────────────────────
const fila = mapeo[campoNorm];
Logger.log('[CONFIG] 🔄 ACTUALIZANDO fila existente: ' + fila);
const rango = sheet.getRange(fila, 2);
rango.setValue(valorFinal);
// Verificación: leer el valor guardado
const valorGuardado = sheet.getRange(fila, 2).getValue();
if (String(valorGuardado) !== String(valorFinal)) {
Logger.log('[CONFIG] ⚠️ ADVERTENCIA: Valor guardado difiere');
Logger.log('[CONFIG] Esperado: ' + valorFinal);
Logger.log('[CONFIG] Guardado: ' + valorGuardado);
} else {
Logger.log('[CONFIG] ✅ Verificado: valor guardado correctamente');
}
camposActualizados++;
} else {
// ── Crear nueva fila ───────────────────────────────────
const nuevaFila = sheet.getLastRow() + 1;
Logger.log('[CONFIG] ➕ CREANDO nueva fila: ' + nuevaFila);
sheet.getRange(nuevaFila, 1).setValue(campo);
sheet.getRange(nuevaFila, 2).setValue(valorFinal);
Logger.log('[CONFIG] ✅ Fila creada exitosamente');
camposCreados++;
}
camposGuardados++;
} catch(e) {
Logger.log('[CONFIG] ❌ ERROR guardando campo: ' + e.message);
Logger.log('[CONFIG] 🔍 Stack: ' + e.stack);
erroresGuardado++;
}
});
// ── RESUMEN FINAL ──────────────────────────────────────────────
Logger.log('═══════════════════════════════════════════════════');
Logger.log('[CONFIG] 📊 RESUMEN DEL GUARDADO:');
Logger.log('[CONFIG] ✅ Campos guardados: ' + camposGuardados);
Logger.log('[CONFIG] ├─ Actualizados: ' + camposActualizados);
Logger.log('[CONFIG] └─ Creados: ' + camposCreados);
Logger.log('[CONFIG] ⏭️ Campos omitidos (vacíos): ' + camposOmitidos);
Logger.log('[CONFIG] ❌ Errores: ' + erroresGuardado);
Logger.log('[CONFIG] ✅ GUARDADO COMPLETADO — sheet: ' + sheetId);
Logger.log('═══════════════════════════════════════════════════');
return R({
ok: true,
guardados: camposGuardados,
actualizados: camposActualizados,
creados: camposCreados,
omitidos: camposOmitidos,
errores: erroresGuardado
});
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: CAMBIAR ESTADO CLIENTE (Activo / Suspendido)
// ══════════════════════════════════════════════════════════════════════
function cambiarEstadoCliente(idKobia, nuevoEstado) {
const ss = SpreadsheetApp.openById(CFG.ID_HOJA_MAESTRA);
const sheet = ss.getSheetByName(CFG.SHEET_MASTER);
if (!sheet) throw new Error('Hoja Clientes no encontrada');
const valores = sheet.getDataRange().getValues();
const rowIndex = valores.findIndex((row, i) => i > 0 && row[0] === idKobia);
if (rowIndex < 0) throw new Error('Cliente no encontrado: ' + idKobia);
sheet.getRange(rowIndex + 1, 7).setValue(nuevoEstado);
Logger.log('[KOBIA] Estado cliente ' + idKobia + ' → ' + nuevoEstado);
return R({ ok: true, idKobia, estado: nuevoEstado });
}
// ══════════════════════════════════════════════════════════════════════
// UTILIDADES
// ══════════════════════════════════════════════════════════════════════
function norm(s) {
if (s === null || s === undefined) return '';
return s.toString().toLowerCase().trim()
.replace(/[\s_\-]+/g, '')
.replace(/[áàä]/g,'a').replace(/[éèë]/g,'e')
.replace(/[íìï]/g,'i').replace(/[óòö]/g,'o')
.replace(/[úùü]/g,'u').replace(/[ñ]/g,'n');
}
function construirFila(hdrs, data, imageUrl, id, estado) {
return hdrs.map(function(h) {
switch (norm(h)) {
case 'id': return id;
case 'nombre': return (data.nombre || '').trim();
case 'descripcion': return (data.descripcion || '').trim();
case 'precio': return parseFloat(data.precio) || 0;
case 'preciooferta':
case 'precio_oferta': return parseFloat(data.precio_oferta) || '';
case 'categoria': return (data.categoria || '').trim();
case 'subcategoria': return (data.subcategoria || '').trim();
case 'imagen': return imageUrl || '';
case 'stock': return parseInt(data.stock) || 1;
case 'destacado': return (data.destacado || '').trim();
case 'estado': return estado || 'activo';
case 'etiqueta': return (data.etiqueta || '').trim();
case 'variante': return (data.variante || '').trim();
default: return '';
}
});
}
function subirImagen(base64, mimeType, nombreBase, carpeta) {
try {
const tipo = mimeType || 'image/jpeg';
const ext = tipo.split('/')[1] || 'jpg';
const nombre = nombreBase + '_' + Date.now() + '.' + ext;
const base64Puro = base64.indexOf('base64,') > -1 ? base64.split('base64,')[1] : base64;
const blob = Utilities.newBlob(Utilities.base64Decode(base64Puro), tipo, nombre);
const file = carpeta.createFile(blob);
// El cambio de permisos a veces falla en cuentas G-Suite/Education, usamos try-catch
try {
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
} catch (e) {
Logger.log('[KOBIA] No se pudo cambiar permisos a "Público", pero el archivo se creó: ' + e.message);
}
const url = 'https://drive.google.com/thumbnail?id=' + file.getId() + '&sz=w1200';
Logger.log('[KOBIA] Imagen subida exitosamente: ' + url);
return url;
} catch (err) {
Logger.log('[KOBIA] ERROR FATAL en subirImagen: ' + err.message);
throw err;
}
}
function obtenerCarpetaCliente(sheetId) {
try {
const archivo = DriveApp.getFileById(sheetId);
const padres = archivo.getParents();
if (padres.hasNext()) {
const carpetaCliente = padres.next();
// Buscar carpeta de imágenes que termine en -IMG
const subCarpetas = carpetaCliente.getFolders();
while (subCarpetas.hasNext()) {
const sub = subCarpetas.next();
if (sub.getName().endsWith('-IMG')) return sub;
}
// Si no existe aún, extraer el ID del cliente del nombre del Sheet
// y crear carpeta con nomenclatura correcta YYMM-NNN-IMG
const nombreSheet = archivo.getName(); // ej: 2603-001-MOD-VALENTINA
const partes = nombreSheet.split('-');
const idCliente = partes.length >= 2 ? `${partes[0]}-${partes[1]}` : 'KOBIA';
return carpetaCliente.createFolder(`${idCliente}-IMG`);
}
} catch(e) {
Logger.log('[KOBIA] No se pudo encontrar carpeta del cliente: ' + e.message);
}
// Fallback: carpeta Clientes dentro de la raíz fija
try {
const raiz = DriveApp.getFolderById(CFG.ID_CARPETA_RAIZ);
const iterCl = raiz.getFoldersByName(CFG.CARPETA_CLIENTES);
return iterCl.hasNext() ? iterCl.next() : raiz.createFolder(CFG.CARPETA_CLIENTES);
} catch(e) {
Logger.log('[KOBIA] Error en fallback de carpeta: ' + e.message);
throw e;
}
}
function obtenerOCrearCarpeta(nombre) {
const iter = DriveApp.getFoldersByName(nombre);
return iter.hasNext() ? iter.next() : DriveApp.createFolder(nombre);
}
function obtenerOCrearHoja(ss, nombre, encabezados) {
let sheet = ss.getSheetByName(nombre);
if (!sheet) {
sheet = ss.insertSheet(nombre);
sheet.getRange(1, 1, 1, encabezados.length).setValues([encabezados]);
sheet.setFrozenRows(1);
Logger.log('[KOBIA] Hoja creada: ' + nombre);
}
return sheet;
}
function R(obj) {
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: REGISTRAR LEAD PENDIENTE (ONBOARDING LANDING PAGE)
// ══════════════════════════════════════════════════════════════════════
function registrarLeadPendiente(data) {
Logger.log('═══════════════════════════════════════════════════');
Logger.log('👤 REGISTRANDO NUEVO LEAD (PRE-CLIENTE)');
Logger.log('═══════════════════════════════════════════════════');
Logger.log('📦 Datos recibidos desde landing:');
Logger.log(JSON.stringify(data, null, 2));
const masterSheetId = CFG.ID_HOJA_MAESTRA;
const ss = SpreadsheetApp.openById(masterSheetId);
const clientsSheet = ss.getSheetByName('Clientes');
// ═══ NORMALIZACIÓN DE CAMPOS ═══════════════════════════════════
// La landing puede enviar campos con diferentes nombres
// Necesitamos ser flexibles y aceptar múltiples variaciones
// CATEGORÍA: acepta "categoria", "category", "tipo", "supercategoria"
let categoriaRaw = data.categoria
|| data.category
|| data.tipo
|| data.supercategoria
|| data.tipoNegocio
|| data.tipo_negocio
|| '';
// MAPEO: Convertir categorías de la landing a supercategorías KOBIA
const MAPEO_CATEGORIAS = {
// Moda
'moda y ropa': 'MOD',
'moda': 'MOD',
'ropa': 'MOD',
'fashion': 'MOD',
// Gastronomía
'comida y restaurante': 'GAS',
'comida': 'GAS',
'restaurante': 'GAS',
'gastronomía': 'GAS',
'gastronomia': 'GAS',
'food': 'GAS',
// Tecnología
'electrónica y tecnología': 'TEC',
'electronica y tecnologia': 'TEC',
'tecnología': 'TEC',
'tecnologia': 'TEC',
'electrónica': 'TEC',
'electronica': 'TEC',
'tech': 'TEC',
// Salud y Belleza
'belleza y salud': 'SAL',
'belleza': 'SAL',
'salud': 'SAL',
'beauty': 'SAL',
'health': 'SAL',
// Mercado
'mercado': 'MER',
'supermercado': 'MER',
'market': 'MER',
// Hogar
'hogar': 'HOG',
'home': 'HOG',
'decoración': 'HOG',
'decoracion': 'HOG',
// Servicios y Variado
'servicios': 'VAR',
'services': 'VAR',
'otro': 'VAR',
'otros': 'VAR',
'other': 'VAR',
'variado': 'VAR'
};
// Normalizar y mapear
const categoriaKey = categoriaRaw.toLowerCase().trim();
const categoria = MAPEO_CATEGORIAS[categoriaKey] || 'VAR'; // Default: VAR
Logger.log('📋 Categoría recibida: "' + categoriaRaw + '" → Mapeada a: "' + categoria + '"');
// NOMBRE MARCA: acepta "nombreMarca", "nombre", "marca", "name"
const nombreMarca = data.nombreMarca
|| data.nombre_marca
|| data.nombre
|| data.marca
|| data.name
|| data.business_name
|| '';
Logger.log('🏷️ Nombre marca extraído: ' + (nombreMarca || '(vacío)'));
// WHATSAPP: acepta "whatsapp", "telefono", "phone", "celular"
const whatsapp = (data.whatsapp
|| data.whatsappId
|| data.whatsapp_id
|| data.telefono
|| data.phone
|| data.celular
|| data.mobile
|| '').replace(/[^0-9]/g, ''); // Limpia todo lo que no sea número
Logger.log('📱 WhatsApp extraído: ' + (whatsapp || '(vacío)'));
// EMAIL: acepta "email", "correo", "mail"
const email = data.email
|| data.correo
|| data.mail
|| '';
Logger.log('📧 Email extraído: ' + (email || '(vacío)'));
// PLAN: acepta "plan", "paquete", "package"
const plan = data.plan
|| data.paquete
|| data.package
|| 'base';
Logger.log('📦 Plan extraído: ' + plan);
// TIPO PLANTILLA: 'catalogo' o 'portafolio'
const tipoPlantilla = (data.tipo_plantilla || data.tipoPlantilla || 'catalogo').toLowerCase().trim();
Logger.log('🎨 Tipo plantilla: ' + tipoPlantilla);
// ═══ VALIDACIONES ═══════════════════════════════════════════════
const errores = [];
if (!nombreMarca) {
errores.push('❌ Nombre de marca vacío');
}
if (!email || !email.includes('@')) {
errores.push('❌ Email inválido o vacío: ' + email);
}
if (!whatsapp || whatsapp.length < 10) {
errores.push('⚠️ WhatsApp vacío o incompleto: ' + whatsapp);
}
if (!categoria) {
errores.push('⚠️ Categoría vacía');
}
if (errores.length > 0) {
Logger.log('⚠️ ADVERTENCIAS EN DATOS:');
errores.forEach(e => Logger.log(' ' + e));
}
// Asignar ID temporal Lead
const idKobiaTemp = 'LEAD-' + Date.now().toString().slice(-4);
const fechaHoy = new Date();
const formatDate = (date) => {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
// ═══ CONSTRUCCIÓN DE FILA ═══════════════════════════════════════
const newRow = [
idKobiaTemp, // A — ID_KOBIA (temporal)
categoria, // B — CATEGORIA
nombreMarca, // C — NOMBRE_MARCA
whatsapp, // D — WHATSAPP_ID
email, // E — EMAIL
'', // F — ID_SHEET_CLIENTE (vacío hasta convertir)
'Pendiente', // G — ESTADO
plan, // H — PLAN
formatDate(fechaHoy), // I — FECHA_INICIO
1, // J — MESES
'', // K — FECHA_FIN (vacío hasta convertir)
'', // L — SLUG (vacío hasta convertir)
tipoPlantilla // M — TIPO_PLANTILLA
];
Logger.log('📊 Fila a insertar:');
Logger.log(' A (ID): ' + newRow[0]);
Logger.log(' B (Categoría): ' + newRow[1]);
Logger.log(' C (Nombre Marca): ' + newRow[2]);
Logger.log(' D (WhatsApp): ' + newRow[3]);
Logger.log(' E (Email): ' + newRow[4]);
Logger.log(' G (Estado): ' + newRow[6]);
// ═══ INSERTAR EN HOJA ═══════════════════════════════════════════
try {
clientsSheet.appendRow(newRow);
Logger.log('✅ Lead registrado exitosamente en hoja Clientes');
} catch(e) {
Logger.log('❌ ERROR insertando lead: ' + e.message);
throw e;
}
// ═══ ENVIAR EMAIL DE VALIDACIÓN ═════════════════════════════════
if (email && email.includes('@')) {
Logger.log('📧 Enviando email de validación...');
try {
sendLeadValidationEmail(email, nombreMarca || 'tu negocio');
Logger.log('✅ Email enviado');
} catch(e) {
Logger.log('⚠️ Error enviando email: ' + e.message);
}
}
Logger.log('═══════════════════════════════════════════════════');
Logger.log('✅ PRE-CLIENTE REGISTRADO COMO PENDIENTE');
Logger.log(' ID: ' + idKobiaTemp);
Logger.log(' Nombre: ' + nombreMarca);
Logger.log(' WhatsApp: ' + whatsapp);
Logger.log(' Email: ' + email);
Logger.log(' Categoría: ' + categoria);
Logger.log('═══════════════════════════════════════════════════');
return R({
ok: true,
mensaje: 'Pre-cliente registrado con estado Pendiente.',
idTemp: idKobiaTemp,
datos: {
nombreMarca,
whatsapp,
email,
categoria,
tipoPlantilla
}
});
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: LOGIN DE CLIENTES EN PANEL
// ══════════════════════════════════════════════════════════════════════
function loginPanel(whatsapp, pin, sheetIdAutorizado) {
const wsMaster = SpreadsheetApp.openById(CFG.ID_HOJA_MAESTRA);
const hojaClientes = wsMaster.getSheetByName(CFG.SHEET_MASTER);
const data = hojaClientes.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().replace(/[\s_\-]+/g, ''));
const targetWhatsapp = String(whatsapp).replace(/[^0-9]/g, '').slice(-10);
const targetPin = String(pin).trim();
let nombreMarca = 'Mi Tienda';
let estado = 'inactivo';
let scriptUrl = '';
let slug = '';
const wIdx = headers.findIndex(h => h === 'whatsappid' || h === 'whatsapp');
const nIdx = headers.findIndex(h => h === 'nombremarca' || h === 'marca' || h === 'nombre');
const eIdx = headers.findIndex(h => h === 'estado' || h === 'status');
const slIdx = headers.findIndex(h => h === 'slug');
for (let i = 1; i < data.length; i++) {
let dbWhatsapp = String(data[i][wIdx]).replace(/[^0-9]/g, '').slice(-10);
let dbPin = String(data[i][0]).trim();
if (dbWhatsapp === targetWhatsapp && dbPin === targetPin) {
if (nIdx >= 0) nombreMarca = data[i][nIdx];
if (eIdx >= 0) estado = data[i][eIdx];
if (slIdx >= 0) slug = data[i][slIdx];
break;
}
}
const estadoVal = String(estado).toLowerCase().trim();
if (!['si','sí','yes','1','true','activo'].includes(estadoVal)) {
return R({ ok: false, error: 'Tu servicio está suspendido. Contacta a KOBIA.' });
}
return R({
ok: true,
sheetId: sheetIdAutorizado,
nombreMarca: nombreMarca,
slug: slug,
scriptUrl: scriptUrl || 'https://script.google.com/macros/s/AKfycbxQVl-5dj2_WRRw8J-Yj76isaYYS-ObaXYBGESBglPgwyW0LchzyTgzzfJJP__KMlti0g/exec'
});
}
// ══════════════════════════════════════════════════════════════════════
// ACCIÓN: LISTAR CLIENTES (SOLO PARA PANEL MASTER CON AUTH)
// ══════════════════════════════════════════════════════════════════════
function listarClientes(data) {
Logger.log('═══════════════════════════════════════════════════');
Logger.log('[KOBIA] 🔐 LISTAR CLIENTES - Autenticación Admin');
Logger.log('═══════════════════════════════════════════════════');
// ═══ VALIDAR CREDENCIALES DE ADMINISTRADOR ═════════════════════
const adminUser = data.adminUser || data.user || '';
const adminPass = data.adminPass || data.pass || '';
Logger.log('[ADMIN] Usuario recibido: ' + (adminUser || '(vacío)'));
Logger.log('[ADMIN] Password recibido: ' + (adminPass ? '***' : '(vacío)'));
if (!adminUser || !adminPass) {
Logger.log('[ADMIN] ❌ ERROR: Credenciales vacías');
return R({
ok: false,
error: 'Se requieren credenciales de administrador',
needsAuth: true
});
}
if (adminUser !== CFG.ADMIN_USER || adminPass !== CFG.ADMIN_PASS) {
Logger.log('[ADMIN] ❌ ERROR: Credenciales incorrectas');
Logger.log('[ADMIN] Esperado user: ' + CFG.ADMIN_USER);
Logger.log('[ADMIN] Recibido user: ' + adminUser);
return R({
ok: false,
error: 'Credenciales de administrador incorrectas',
needsAuth: true
});
}
Logger.log('[ADMIN] ✅ Autenticación exitosa');
// ═══ LEER HOJA CLIENTES (PRIVADA) ══════════════════════════════
try {
const ss = SpreadsheetApp.openById(CFG.ID_HOJA_MAESTRA);
const sheet = ss.getSheetByName(CFG.SHEET_MASTER);
if (!sheet) {
Logger.log('[ADMIN] ❌ ERROR: Hoja Clientes no encontrada');
return R({ ok: false, error: 'Hoja Clientes no encontrada' });
}
Logger.log('[ADMIN] ✅ Hoja Clientes abierta');
// Leer todos los datos
const valores = sheet.getDataRange().getValues();
if (valores.length < 2) {
Logger.log('[ADMIN] ⚠️ Hoja vacía, solo headers');
return R({ ok: true, clientes: [] });
}
// Headers normalizados
const headers = valores[0].map(h =>
String(h).toLowerCase().trim().replace(/[\s_\-]+/g, '')
);
Logger.log('[ADMIN] 📋 Headers: ' + headers.join(', '));
// Convertir filas a objetos
const clientes = [];
for (let i = 1; i < valores.length; i++) {
const row = valores[i];
const cliente = {};
headers.forEach((h, idx) => {
cliente[h] = row[idx] || '';
});
// Solo agregar si tiene ID_KOBIA
if (cliente.idkobia || cliente.id) {
clientes.push(cliente);
}
}
Logger.log('[ADMIN] ✅ Clientes procesados: ' + clientes.length);
Logger.log('═══════════════════════════════════════════════════');
return R({
ok: true,
clientes: clientes,
total: clientes.length
});
} catch(e) {
Logger.log('[ADMIN] ❌ ERROR leyendo hoja: ' + e.message);
Logger.log('[ADMIN] 🔍 Stack: ' + e.stack);
return R({
ok: false,
error: 'Error al leer datos: ' + e.message
});
}
}
// ══════════════════════════════════════════════════════════════════════
// FUNCIÓN DE PRUEBA: EJECUTA DESDE EL EDITOR DE APPS SCRIPT
// ══════════════════════════════════════════════════════════════════════
function testKobiaEmail() {
const testEmail = "soporte.kobia.com.co@gmail.com"; // O tu correo personal para probar
Logger.log("📧 Iniciando envío de prueba a: " + testEmail);
try {
sendLeadValidationEmail(testEmail, "Tienda de Prueba KOBIA");
Logger.log("✅ Prueba completada. Revisa tu bandeja de entrada y spam.");
} catch (error) {
Logger.log("❌ Error en prueba: " + error);
}
}