diff --git a/db/migrations/20260501130000_seed_piaf_settings_and_replies.sql b/db/migrations/20260501130000_seed_piaf_settings_and_replies.sql index 1a36c97..ff09810 100644 --- a/db/migrations/20260501130000_seed_piaf_settings_and_replies.sql +++ b/db/migrations/20260501130000_seed_piaf_settings_and_replies.sql @@ -8,33 +8,18 @@ UPDATE tenant_settings SET store_name = COALESCE(NULLIF(store_name, 'Mi Negocio'), 'Piaf'), bot_name = COALESCE(NULLIF(bot_name, 'Bot'), 'Piaf'), - delivery_enabled = true, pickup_enabled = true, schedule = '{ - "delivery": { - "mon": {"enabled": true, "start": "09:00", "end": "13:00"}, - "tue": {"enabled": true, "start": "09:00", "end": "13:00"}, - "wed": {"enabled": true, "start": "09:00", "end": "13:00"}, - "thu": {"enabled": true, "start": "09:00", "end": "13:00"}, - "fri": {"enabled": true, "start": "09:00", "end": "13:00"}, - "sat": {"enabled": true, "start": "09:00", "end": "13:00"}, - "sun": {"enabled": false} - }, "pickup": { - "mon": {"enabled": true, "start": "09:00", "end": "20:00"}, - "tue": {"enabled": true, "start": "09:00", "end": "20:00"}, - "wed": {"enabled": true, "start": "09:00", "end": "20:00"}, - "thu": {"enabled": true, "start": "09:00", "end": "20:00"}, - "fri": {"enabled": true, "start": "09:00", "end": "20:00"}, - "sat": {"enabled": true, "start": "09:00", "end": "13:00"}, - "sun": {"enabled": false} + "lun": {"enabled": true, "start": "09:00", "end": "20:00"}, + "mar": {"enabled": true, "start": "09:00", "end": "20:00"}, + "mie": {"enabled": true, "start": "09:00", "end": "20:00"}, + "jue": {"enabled": true, "start": "09:00", "end": "20:00"}, + "vie": {"enabled": true, "start": "09:00", "end": "20:00"}, + "sab": {"enabled": true, "start": "09:00", "end": "13:00"} } }'::jsonb, - delivery_zones = '{ - "caba": { - "barrios": ["Palermo", "Belgrano", "Recoleta", "Villa Crespo", "Almagro", "Caballito", "Núñez", "Colegiales", "Chacarita", "Las Cañitas"] - } - }'::jsonb + delivery_zones = '{}'::jsonb WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid; -- 2) reply_templates: seed con DEFAULTS de replyTemplates.js para piaf diff --git a/db/migrations/20260502183357_drop_legacy_delivery_columns.sql b/db/migrations/20260502183357_drop_legacy_delivery_columns.sql new file mode 100644 index 0000000..ddfa0ce --- /dev/null +++ b/db/migrations/20260502183357_drop_legacy_delivery_columns.sql @@ -0,0 +1,18 @@ +-- migrate:up +-- Borrar columnas obsoletas de tenant_settings: ahora cada zona de delivery +-- tiene su propio costo, días y rango horario. Schedule.delivery también queda +-- obsoleto (sólo pickup hace sentido como horario único de la tienda física). +ALTER TABLE tenant_settings + DROP COLUMN IF EXISTS delivery_enabled, + DROP COLUMN IF EXISTS delivery_days, + DROP COLUMN IF EXISTS delivery_hours_start, + DROP COLUMN IF EXISTS delivery_hours_end, + DROP COLUMN IF EXISTS delivery_min_order; + +UPDATE tenant_settings +SET schedule = (schedule - 'delivery') +WHERE schedule ? 'delivery'; + +-- migrate:down +-- noop: no preservamos los campos legacy. +SELECT 1; diff --git a/public/components/settings-crud.js b/public/components/settings-crud.js index 39c542e..5251f94 100644 --- a/public/components/settings-crud.js +++ b/public/components/settings-crud.js @@ -408,30 +408,6 @@ class SettingsCrud extends HTMLElement { - -
-
- - Delivery (Envío a domicilio) -
- -
-
- Delivery habilitado -
- -
- ${this.renderScheduleGrid("delivery", s.delivery_enabled)} -
- -
-
- - -
-
-
-
@@ -490,13 +466,6 @@ class SettingsCrud extends HTMLElement { } setupEventListeners() { - // Toggle delivery - const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle"); - deliveryToggle?.addEventListener("click", () => { - this.settings.delivery_enabled = !this.settings.delivery_enabled; - this.render(); - }); - // Toggle pickup const pickupToggle = this.shadowRoot.getElementById("pickupToggle"); pickupToggle?.addEventListener("click", () => { @@ -673,23 +642,19 @@ class SettingsCrud extends HTMLElement { } collectScheduleFromInputs() { - const schedule = { delivery: {}, pickup: {} }; - - for (const type of ["delivery", "pickup"]) { - this.shadowRoot.querySelectorAll(`.hour-start[data-type="${type}"]`).forEach(input => { - const day = input.dataset.day; - const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="${type}"][data-day="${day}"]`); - const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="${type}"][data-day="${day}"]`); - - if (toggle?.classList.contains("active")) { - schedule[type][day] = { - start: input.value.trim() || (type === "delivery" ? "09:00" : "08:00"), - end: endInput?.value.trim() || (type === "delivery" ? "18:00" : "20:00"), - }; - } - }); - } - + // Sólo pickup: el horario de delivery vive ahora dentro de cada zona. + const schedule = { pickup: {} }; + this.shadowRoot.querySelectorAll(`.hour-start[data-type="pickup"]`).forEach((input) => { + const day = input.dataset.day; + const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="pickup"][data-day="${day}"]`); + const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="pickup"][data-day="${day}"]`); + if (toggle?.classList.contains("active")) { + schedule.pickup[day] = { + start: input.value.trim() || "08:00", + end: endInput?.value.trim() || "20:00", + }; + } + }); return schedule; } @@ -727,9 +692,7 @@ class SettingsCrud extends HTMLElement { bot_name: this.shadowRoot.getElementById("botName")?.value || "", store_address: this.shadowRoot.getElementById("storeAddress")?.value || "", store_phone: this.shadowRoot.getElementById("storePhone")?.value || "", - delivery_enabled: this.settings.delivery_enabled, pickup_enabled: this.settings.pickup_enabled, - delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0, schedule, delivery_zones, }; diff --git a/public/components/zone-map-editor.js b/public/components/zone-map-editor.js index 8ef5385..fc8a589 100644 --- a/public/components/zone-map-editor.js +++ b/public/components/zone-map-editor.js @@ -37,46 +37,44 @@ const ZONE_PALETTE = [ "--chart-orange", "--chart-pink", "--chart-gray", ]; -let _libsPromise = null; -function ensureLeaflet() { +let _scriptsPromise = null; +function ensureLeafletScripts() { if (window.L && window.L.PM) return Promise.resolve(); - if (_libsPromise) return _libsPromise; - _libsPromise = (async () => { - if (!document.querySelector(`link[href="${LEAFLET_CSS}"]`)) { - const l1 = document.createElement("link"); - l1.rel = "stylesheet"; l1.href = LEAFLET_CSS; - document.head.appendChild(l1); - } - if (!document.querySelector(`link[href="${GEOMAN_CSS}"]`)) { - const l2 = document.createElement("link"); - l2.rel = "stylesheet"; l2.href = GEOMAN_CSS; - document.head.appendChild(l2); - } - if (!window.L) { - await loadScript(LEAFLET_JS); - } - if (!window.L.PM) { - await loadScript(GEOMAN_JS); - } + if (_scriptsPromise) return _scriptsPromise; + _scriptsPromise = (async () => { + if (!window.L) await loadScript(LEAFLET_JS); + if (!window.L.PM) await loadScript(GEOMAN_JS); })(); - return _libsPromise; + return _scriptsPromise; } function loadScript(src) { return new Promise((resolve, reject) => { - if (document.querySelector(`script[src="${src}"]`)) { - resolve(); + const existing = document.querySelector(`script[src="${src}"]`); + if (existing) { + if (existing.dataset.loaded === "1") { resolve(); return; } + existing.addEventListener("load", () => resolve()); + existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`))); return; } const s = document.createElement("script"); s.src = src; s.async = true; - s.onload = () => resolve(); + s.onload = () => { s.dataset.loaded = "1"; resolve(); }; s.onerror = () => reject(new Error(`Failed to load ${src}`)); document.head.appendChild(s); }); } +function waitForCSS(linkEl, timeoutMs = 4000) { + if (linkEl.sheet) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + linkEl.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true }); + linkEl.addEventListener("error", () => { clearTimeout(timer); resolve(); }, { once: true }); + }); +} + function cssVar(name, fallback = "#0ea5e9") { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; @@ -107,20 +105,48 @@ class ZoneMapEditor extends HTMLElement { this.style.position = "relative"; this.style.width = "100%"; this.style.height = this.getAttribute("height") || "480px"; + + // El web component vive en light DOM dentro del shadow root del padre. + // Los en document.head NO cruzan shadow boundaries, así que los + // anclamos como hijos del propio elemento (sí cruzan, porque slot + // composition trae nuestros children al árbol del padre con sus assets). + const linkLeaflet = document.createElement("link"); + linkLeaflet.rel = "stylesheet"; + linkLeaflet.href = LEAFLET_CSS; + this.appendChild(linkLeaflet); + const linkGeoman = document.createElement("link"); + linkGeoman.rel = "stylesheet"; + linkGeoman.href = GEOMAN_CSS; + this.appendChild(linkGeoman); + this._mapDiv = document.createElement("div"); this._mapDiv.style.width = "100%"; this._mapDiv.style.height = "100%"; this._mapDiv.style.borderRadius = "var(--r-md, 10px)"; this._mapDiv.style.overflow = "hidden"; this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)"; + this._mapDiv.style.background = "#e2e8f0"; this.appendChild(this._mapDiv); - ensureLeaflet().then(() => this._initMap()).catch((err) => { - this._mapDiv.innerHTML = `
No se pudo cargar el mapa: ${err.message}
`; - }); + ensureLeafletScripts() + .then(() => waitForCSS(linkLeaflet)) + .then(() => this._initMap()) + .catch((err) => { + this._mapDiv.innerHTML = `
No se pudo cargar el mapa: ${err.message}
`; + }); + + // Si el host estaba oculto al montar (router/visibility), Leaflet calcula + // 0×0 y los tiles no se piden. Observamos el resize para recalcular. + if (typeof ResizeObserver !== "undefined") { + this._ro = new ResizeObserver(() => { + if (this._map) this._map.invalidateSize(); + }); + this._ro.observe(this); + } } disconnectedCallback() { + if (this._ro) { this._ro.disconnect(); this._ro = null; } if (this._map) { this._map.remove(); this._map = null; @@ -226,6 +252,10 @@ class ZoneMapEditor extends HTMLElement { this._ready = true; this._renderLayers(); + // Forzar un invalidateSize después del primer paint por si el contenedor + // recién obtuvo tamaño (panel oculto inicial / tabs / etc). + requestAnimationFrame(() => this._map && this._map.invalidateSize()); + setTimeout(() => this._map && this._map.invalidateSize(), 250); } _renderLayers() { diff --git a/src/modules/0-ui/db/settingsRepo.js b/src/modules/0-ui/db/settingsRepo.js index caa151a..f8d3a18 100644 --- a/src/modules/0-ui/db/settingsRepo.js +++ b/src/modules/0-ui/db/settingsRepo.js @@ -3,19 +3,20 @@ import { pool } from "../../shared/db/pool.js"; // ───────────────────────────────────────────────────────────── // Tenant Settings - CRUD // ───────────────────────────────────────────────────────────── +// +// Modelo actual: +// - Datos del comercio: store_name, bot_name, store_address, store_phone. +// - Pickup (retiro en tienda): pickup_enabled + pickup_days CSV + +// pickup_hours_start/end TIME, y `schedule.pickup` JSONB para horario por día. +// - Delivery: TODO vive en `delivery_zones.zones[]` (polígonos GeoJSON con +// costo, días y rango horario por zona). El bot valida zona usando la +// ubicación que el cliente comparte por WhatsApp. -/** - * Obtiene la configuración del tenant - */ export async function getSettings({ tenantId }) { const sql = ` - SELECT + SELECT id, tenant_id, store_name, bot_name, store_address, store_phone, - delivery_enabled, delivery_days, - delivery_hours_start::text as delivery_hours_start, - delivery_hours_end::text as delivery_hours_end, - delivery_min_order, pickup_enabled, pickup_days, pickup_hours_start::text as pickup_hours_start, pickup_hours_end::text as pickup_hours_end, @@ -30,20 +31,12 @@ export async function getSettings({ tenantId }) { return rows[0] || null; } -/** - * Crea o actualiza la configuración del tenant (upsert) - */ export async function upsertSettings({ tenantId, settings }) { const { store_name, bot_name, store_address, store_phone, - delivery_enabled, - delivery_days, - delivery_hours_start, - delivery_hours_end, - delivery_min_order, pickup_enabled, pickup_days, pickup_hours_start, @@ -55,21 +48,15 @@ export async function upsertSettings({ tenantId, settings }) { const sql = ` INSERT INTO tenant_settings ( tenant_id, store_name, bot_name, store_address, store_phone, - delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order, pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end, schedule, delivery_zones ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (tenant_id) DO UPDATE SET store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name), bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name), store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address), store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone), - delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled), - delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days), - delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start), - delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end), - delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order), pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled), pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days), pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start), @@ -77,13 +64,9 @@ export async function upsertSettings({ tenantId, settings }) { schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule), delivery_zones = COALESCE(EXCLUDED.delivery_zones, tenant_settings.delivery_zones), updated_at = NOW() - RETURNING + RETURNING id, tenant_id, store_name, bot_name, store_address, store_phone, - delivery_enabled, delivery_days, - delivery_hours_start::text as delivery_hours_start, - delivery_hours_end::text as delivery_hours_end, - delivery_min_order, pickup_enabled, pickup_days, pickup_hours_start::text as pickup_hours_start, pickup_hours_end::text as pickup_hours_end, @@ -98,11 +81,6 @@ export async function upsertSettings({ tenantId, settings }) { bot_name || null, store_address || null, store_phone || null, - delivery_enabled ?? null, - delivery_days || null, - delivery_hours_start || null, - delivery_hours_end || null, - delivery_min_order ?? null, pickup_enabled ?? null, pickup_days || null, pickup_hours_start || null, @@ -110,150 +88,103 @@ export async function upsertSettings({ tenantId, settings }) { schedule ? JSON.stringify(schedule) : null, delivery_zones ? JSON.stringify(delivery_zones) : null, ]; - - const { rows } = await pool.query(sql, params); + const { rows } = await pool.query(sql, params); return rows[0]; } /** - * Formatea horarios desde schedule JSONB para mostrar de forma natural - * Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs, Sáb de 9 a 13hs" + * Formatea schedule.pickup ({ lun: { start, end } }) en prosa para mostrar al + * cliente. Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs". */ -function formatScheduleHours(scheduleType, enabled) { - if (!enabled || !scheduleType || typeof scheduleType !== "object") { +function formatScheduleHours(scheduleObj, enabled) { + if (!enabled || !scheduleObj || typeof scheduleObj !== "object") { return enabled === false ? "No disponible" : ""; } const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]; const dayNames = { - lun: "Lunes", mar: "Martes", mie: "Miércoles", - jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo" + lun: "Lunes", mar: "Martes", mie: "Miércoles", + jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo", }; - // Agrupar días por horario const groups = {}; for (const day of dayOrder) { - const slot = scheduleType[day]; + const slot = scheduleObj[day]; if (!slot || !slot.start || !slot.end) continue; - const key = `${slot.start}-${slot.end}`; - if (!groups[key]) { - groups[key] = { start: slot.start, end: slot.end, days: [] }; - } + if (!groups[key]) groups[key] = { start: slot.start, end: slot.end, days: [] }; groups[key].days.push(day); } - if (Object.keys(groups).length === 0) return ""; + if (!Object.keys(groups).length) return ""; - // Formatear cada grupo - const parts = Object.values(groups).map(g => { + const parts = Object.values(groups).map((g) => { const days = g.days; let dayStr; - - // Detectar rangos consecutivos if (days.length >= 3) { - const indices = days.map(d => dayOrder.indexOf(d)); - const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i-1] + 1); - if (isConsecutive) { - dayStr = `${dayNames[days[0]]} a ${dayNames[days[days.length-1]]}`; - } else { - dayStr = days.map(d => dayNames[d]).join(", "); - } + const indices = days.map((d) => dayOrder.indexOf(d)); + const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1); + dayStr = isConsecutive + ? `${dayNames[days[0]]} a ${dayNames[days[days.length - 1]]}` + : days.map((d) => dayNames[d]).join(", "); } else if (days.length === 2) { dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`; } else { dayStr = dayNames[days[0]]; } - - const startH = g.start.slice(0, 5); - const endH = g.end.slice(0, 5); - return `${dayStr} de ${startH} a ${endH}`; + return `${dayStr} de ${g.start.slice(0, 5)} a ${g.end.slice(0, 5)}`; }); return parts.join(", "); } +function formatLegacyPickupHours(enabled, days, start, end) { + if (!enabled) return "No disponible"; + if (!days || !start || !end) return ""; + const daysFormatted = days.split(",").map((d) => d.trim()).join(", "); + return `${daysFormatted} de ${start.slice(0, 5)} a ${end.slice(0, 5)}`; +} + /** - * Obtiene la configuración formateada para usar en prompts (storeConfig) + * Forma de la storeConfig que consume el agente y su workingMemory. */ export async function getStoreConfig({ tenantId }) { const settings = await getSettings({ tenantId }); - + if (!settings) { - // Valores por defecto si no hay configuración return { name: "la carnicería", botName: "Piaf", hours: "", address: "", phone: "", - deliveryHours: "", pickupHours: "", schedule: null, + delivery_zones: {}, }; } const schedule = settings.schedule || {}; - // Usar nuevo formato schedule si existe, sino legacy - let deliveryHours, pickupHours; - - if (schedule.delivery && Object.keys(schedule.delivery).length > 0) { - deliveryHours = formatScheduleHours(schedule.delivery, settings.delivery_enabled); - } else { - // Legacy format - deliveryHours = formatLegacyHours( - settings.delivery_enabled, - settings.delivery_days, - settings.delivery_hours_start, - settings.delivery_hours_end - ); - } - - if (schedule.pickup && Object.keys(schedule.pickup).length > 0) { - pickupHours = formatScheduleHours(schedule.pickup, settings.pickup_enabled); - } else { - // Legacy format - pickupHours = formatLegacyHours( - settings.pickup_enabled, - settings.pickup_days, - settings.pickup_hours_start, - settings.pickup_hours_end - ); - } - - // Combinar horarios para store_hours (usa pickup como horario de tienda) - let storeHours = ""; - if (settings.pickup_enabled) { - storeHours = pickupHours; - } + const pickupHours = schedule.pickup && Object.keys(schedule.pickup).length + ? formatScheduleHours(schedule.pickup, settings.pickup_enabled) + : formatLegacyPickupHours( + settings.pickup_enabled, + settings.pickup_days, + settings.pickup_hours_start, + settings.pickup_hours_end, + ); return { name: settings.store_name || "la carnicería", botName: settings.bot_name || "Piaf", - hours: storeHours, + hours: settings.pickup_enabled ? pickupHours : "", address: settings.store_address || "", phone: settings.store_phone || "", - deliveryHours, pickupHours, - deliveryEnabled: settings.delivery_enabled, pickupEnabled: settings.pickup_enabled, schedule, delivery_zones: settings.delivery_zones || {}, }; } - -/** - * Formatear horarios en formato legacy (días + rango único) - */ -function formatLegacyHours(enabled, days, start, end) { - if (!enabled) return "No disponible"; - if (!days || !start || !end) return ""; - - const daysFormatted = days.split(",").map(d => d.trim()).join(", "); - const startFormatted = start?.slice(0, 5) || ""; - const endFormatted = end?.slice(0, 5) || ""; - - return `${daysFormatted} de ${startFormatted} a ${endFormatted}`; -} diff --git a/src/modules/0-ui/handlers/settings.js b/src/modules/0-ui/handlers/settings.js index 86da45b..982f3ff 100644 --- a/src/modules/0-ui/handlers/settings.js +++ b/src/modules/0-ui/handlers/settings.js @@ -1,63 +1,42 @@ import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js"; -// Días de la semana para validación const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]; -/** - * Genera schedule por defecto con horarios uniformes - */ -function createDefaultSchedule() { - const defaultDays = ["lun", "mar", "mie", "jue", "vie", "sab"]; - const delivery = {}; +function defaultPickupSchedule() { + const days = ["lun", "mar", "mie", "jue", "vie", "sab"]; const pickup = {}; - - for (const day of defaultDays) { - delivery[day] = { start: "09:00", end: "18:00" }; - pickup[day] = { start: "08:00", end: "20:00" }; - } - - return { delivery, pickup }; + for (const d of days) pickup[d] = { start: "08:00", end: "20:00" }; + return { pickup }; } -/** - * Obtiene la configuración actual del tenant - */ export async function handleGetSettings({ tenantId }) { const settings = await getSettings({ tenantId }); - - // Si no hay configuración, devolver defaults + if (!settings) { return { store_name: "Mi Negocio", bot_name: "Piaf", store_address: "", store_phone: "", - delivery_enabled: true, - delivery_days: "lun,mar,mie,jue,vie,sab", - delivery_hours_start: "09:00", - delivery_hours_end: "18:00", - delivery_min_order: 0, pickup_enabled: true, pickup_days: "lun,mar,mie,jue,vie,sab", pickup_hours_start: "08:00", pickup_hours_end: "20:00", - schedule: createDefaultSchedule(), + schedule: defaultPickupSchedule(), delivery_zones: {}, is_default: true, }; } - // Si no tiene schedule, generar desde datos legacy + // Si schedule está vacío pero tenemos los campos legacy de pickup, generar + // schedule.pickup para que la UI pueda editar el grid por día. let schedule = settings.schedule; - if (!schedule || Object.keys(schedule).length === 0) { - schedule = buildScheduleFromLegacy(settings); + if (!schedule || !schedule.pickup || !Object.keys(schedule.pickup).length) { + schedule = buildPickupScheduleFromLegacy(settings); } return { ...settings, - // Formatear horarios TIME a HH:MM - delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00", - delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00", pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00", pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00", schedule, @@ -66,171 +45,95 @@ export async function handleGetSettings({ tenantId }) { }; } -/** - * Construye schedule desde datos legacy - */ -function buildScheduleFromLegacy(settings) { - const schedule = { delivery: {}, pickup: {} }; - - // Delivery - if (settings.delivery_enabled && settings.delivery_days) { - const days = settings.delivery_days.split(",").map(d => d.trim()); - const start = settings.delivery_hours_start?.slice(0, 5) || "09:00"; - const end = settings.delivery_hours_end?.slice(0, 5) || "18:00"; - for (const day of days) { - if (VALID_DAYS.includes(day)) { - schedule.delivery[day] = { start, end }; - } - } - } - - // Pickup - if (settings.pickup_enabled && settings.pickup_days) { - const days = settings.pickup_days.split(",").map(d => d.trim()); +function buildPickupScheduleFromLegacy(settings) { + const out = { pickup: {} }; + if (settings?.pickup_enabled && settings?.pickup_days) { + const days = settings.pickup_days.split(",").map((d) => d.trim()); const start = settings.pickup_hours_start?.slice(0, 5) || "08:00"; const end = settings.pickup_hours_end?.slice(0, 5) || "20:00"; for (const day of days) { - if (VALID_DAYS.includes(day)) { - schedule.pickup[day] = { start, end }; - } + if (VALID_DAYS.includes(day)) out.pickup[day] = { start, end }; } } - - return schedule; + return out; } -/** - * Valida la estructura del schedule - */ function validateSchedule(schedule) { if (!schedule || typeof schedule !== "object") return; - - // Acepta HH:MM o HH:MM:SS (la BD puede devolver con segundos) const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/; - - for (const type of ["delivery", "pickup"]) { - const typeSchedule = schedule[type]; - if (!typeSchedule || typeof typeSchedule !== "object") continue; - - for (const [day, slot] of Object.entries(typeSchedule)) { - if (!VALID_DAYS.includes(day)) { - throw new Error(`Invalid day in schedule.${type}: ${day}`); - } - - if (slot === null) continue; // null = no disponible - - if (typeof slot !== "object" || !slot.start || !slot.end) { - throw new Error(`Invalid slot format for ${type}.${day}`); - } - - if (!timeRegex.test(slot.start)) { - throw new Error(`Invalid start time for ${type}.${day}: ${slot.start}`); - } - if (!timeRegex.test(slot.end)) { - throw new Error(`Invalid end time for ${type}.${day}: ${slot.end}`); + const pickup = schedule.pickup; + if (!pickup || typeof pickup !== "object") return; + for (const [day, slot] of Object.entries(pickup)) { + if (!VALID_DAYS.includes(day)) throw new Error(`Invalid day in schedule.pickup: ${day}`); + if (slot === null) continue; + if (typeof slot !== "object" || !slot.start || !slot.end) { + throw new Error(`Invalid slot format for pickup.${day}`); + } + if (!timeRegex.test(slot.start)) throw new Error(`Invalid start time for pickup.${day}: ${slot.start}`); + if (!timeRegex.test(slot.end)) throw new Error(`Invalid end time for pickup.${day}: ${slot.end}`); + } +} + +function syncPickupLegacyFromSchedule(settings) { + const pickup = settings?.schedule?.pickup; + if (!pickup) return; + const days = Object.keys(pickup).filter((d) => pickup[d] && pickup[d].start && pickup[d].end); + days.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b)); + if (days.length) { + settings.pickup_days = days.join(","); + const first = pickup[days[0]]; + settings.pickup_hours_start = first.start; + settings.pickup_hours_end = first.end; + } else { + settings.pickup_days = ""; + } +} + +function validateDeliveryZones(dz) { + if (!dz || typeof dz !== "object") return; + if (dz.zones && !Array.isArray(dz.zones)) { + throw new Error("delivery_zones.zones must be an array"); + } + for (const z of dz.zones || []) { + if (!z?.id || !z?.name) throw new Error("Each zone needs id + name"); + if (z.polygon && (z.polygon.type !== "Polygon" || !Array.isArray(z.polygon.coordinates))) { + throw new Error(`Invalid polygon GeoJSON for zone ${z.id}`); + } + if (Array.isArray(z.delivery_days)) { + for (const d of z.delivery_days) { + if (!VALID_DAYS.includes(d)) throw new Error(`Invalid delivery day in zone ${z.id}: ${d}`); } } } } -/** - * Sincroniza campos legacy desde schedule - */ -function syncLegacyFromSchedule(settings) { - const schedule = settings.schedule; - if (!schedule) return; - - // Sincronizar delivery - if (schedule.delivery) { - const deliveryDays = Object.keys(schedule.delivery).filter(d => schedule.delivery[d] !== null); - if (deliveryDays.length > 0) { - // Ordenar días - deliveryDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b)); - settings.delivery_days = deliveryDays.join(","); - - // Usar primer horario como legacy - const firstSlot = schedule.delivery[deliveryDays[0]]; - if (firstSlot) { - settings.delivery_hours_start = firstSlot.start; - settings.delivery_hours_end = firstSlot.end; - } - } else { - settings.delivery_days = ""; - } - } - - // Sincronizar pickup - if (schedule.pickup) { - const pickupDays = Object.keys(schedule.pickup).filter(d => schedule.pickup[d] !== null); - if (pickupDays.length > 0) { - pickupDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b)); - settings.pickup_days = pickupDays.join(","); - - const firstSlot = schedule.pickup[pickupDays[0]]; - if (firstSlot) { - settings.pickup_hours_start = firstSlot.start; - settings.pickup_hours_end = firstSlot.end; - } - } else { - settings.pickup_days = ""; - } - } -} - -/** - * Guarda la configuración del tenant - */ export async function handleSaveSettings({ tenantId, settings }) { - // Validaciones básicas - if (!settings.store_name?.trim()) { - throw new Error("store_name is required"); - } - if (!settings.bot_name?.trim()) { - throw new Error("bot_name is required"); - } + if (!settings.store_name?.trim()) throw new Error("store_name is required"); + if (!settings.bot_name?.trim()) throw new Error("bot_name is required"); - // Validar schedule si viene if (settings.schedule) { validateSchedule(settings.schedule); - // Sincronizar campos legacy desde schedule - syncLegacyFromSchedule(settings); - } else { - // Legacy: validar días individuales - if (settings.delivery_days) { - const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase()); - for (const day of days) { - if (!VALID_DAYS.includes(day)) { - throw new Error(`Invalid delivery day: ${day}`); - } - } - settings.delivery_days = days.join(","); - } + syncPickupLegacyFromSchedule(settings); + } - if (settings.pickup_days) { - const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase()); - for (const day of days) { - if (!VALID_DAYS.includes(day)) { - throw new Error(`Invalid pickup day: ${day}`); - } - } - settings.pickup_days = days.join(","); - } + if (settings.delivery_zones) { + validateDeliveryZones(settings.delivery_zones); + } - // Validar horarios legacy - const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; - - if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) { - throw new Error("Invalid delivery_hours_start format (use HH:MM)"); - } - if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) { - throw new Error("Invalid delivery_hours_end format (use HH:MM)"); - } - if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) { - throw new Error("Invalid pickup_hours_start format (use HH:MM)"); - } - if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) { - throw new Error("Invalid pickup_hours_end format (use HH:MM)"); + if (settings.pickup_days) { + const days = settings.pickup_days.split(",").map((d) => d.trim().toLowerCase()); + for (const d of days) { + if (!VALID_DAYS.includes(d)) throw new Error(`Invalid pickup day: ${d}`); } + settings.pickup_days = days.join(","); + } + + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) { + throw new Error("Invalid pickup_hours_start format (use HH:MM)"); + } + if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) { + throw new Error("Invalid pickup_hours_end format (use HH:MM)"); } const result = await upsertSettings({ tenantId, settings }); @@ -239,8 +142,6 @@ export async function handleSaveSettings({ tenantId, settings }) { ok: true, settings: { ...result, - delivery_hours_start: result.delivery_hours_start?.slice(0, 5), - delivery_hours_end: result.delivery_hours_end?.slice(0, 5), pickup_hours_start: result.pickup_hours_start?.slice(0, 5), pickup_hours_end: result.pickup_hours_end?.slice(0, 5), delivery_zones: result.delivery_zones || {}, @@ -249,9 +150,6 @@ export async function handleSaveSettings({ tenantId, settings }) { }; } -/** - * Obtiene el storeConfig formateado para prompts - */ export async function handleGetStoreConfig({ tenantId }) { return await getStoreConfig({ tenantId }); }