Compare commits

...
10 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 34322ae3dc Change "Reporte enviado" to "Reporte generado"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 20:10:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 97a48d0fe4 Validate dynamic_list input and fix cosecha button IDs
- dynamic_list now stores valid IDs in collecting state; rejects
  free-text input that doesn't match a list option
- Fixed submenu_ciclo_cosecha button IDs to match real flow names
  (ciclo_cosecha_dia / ciclo_cosecha_historico)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 20:07:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0ce766a9eb feat: kilos por lotes con selección de período (mes actual/anterior/personalizado)
- Add last_month date_mode to executeApiReport
- prod_kilos_lotes now shows period menu instead of immediately asking dates
- lotes_mes_actual / lotes_mes_anterior use api_report with date_mode
- lotes_personalizado collects fecha_desde + fecha_hasta (2 messages only when needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:35:12 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 601169525e fix: dedicated save button for categories + preserve all extra DB keys
- Replace onchange+AJAX with explicit button '💾 Guardar categorías'
  that shows alert on success/error for clear feedback
- Get company_id from DOM (not COMPANY_ID JS const from another script block)
- Main form save now preserves welcome_menu and per_type from DB even if
  POST is truncated by max_input_vars
- Restore welcome_menu='welcome_msg' in DB (was wiped by a previous save)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:16:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ae5b7266d4 fix: per_type is DB-only, main form save never touches it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:11:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3c79fac8d7 fix: preserve per_type from DB on main form save
The main save rebuilt config from scratch — per_type always started empty,
so clicking Guardar wiped whatever savePerType() had just stored.

Now: load existing DB config first, use its per_type as the base, apply
POST per_type_menu on top only when it is present. Same for welcome_menu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:09:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8f51659f1e fix: save per-category menus via dedicated endpoint, not the main form
The main form exceeded max_input_vars (27 flows × 37 fields each) so PHP
silently dropped per_type_menu before reaching the save handler.

- Add POST /admin/bot-config/save-per-type — reads existing DB config,
  merges only the per_type_menu values, saves back
- Per-type selects fire savePerType() onchange → AJAX → toast on success
- Add public/.user.ini with max_input_vars=5000 as belt+suspenders
- Remove array_is_list() (PHP 8.1) — replaced with PHP 7.4 compatible check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:06:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cae448a9c6 fix: use $menus after it is defined (was undefined in welcomeMenuOpts loop)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 19:02:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9b0e6ecb71 fix: PHP 7.4 compat — remove union return types from session handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:53:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 37eef6a705 fix: persist admin sessions in DB so deploys don't log out users
Store PHP sessions in the `sessions` MySQL table (30-day TTL) instead of
server files, which disappear on container restart / redeploy.
Cookie lifetime also extended to 30 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:42:33 -05:00
6 changed files with 142 additions and 21 deletions
+26 -3
View File
@@ -2875,14 +2875,14 @@ HTML;
$approvalWebhookVal = self::h($config['approval_webhook'] ?? '');
$ignoreVal = self::h(implode("\n", $config['ignore_prefixes'] ?? []));
$welcomeMenuVal = self::h($config['welcome_menu'] ?? '');
$commands = $config['commands'] ?? [];
$menus = $config['menus'] ?? [];
$flows = $config['flows'] ?? [];
$welcomeMenuOpts = '';
foreach (array_keys($menus) as $mk) {
$s = ($config['welcome_menu'] ?? '') === $mk ? ' selected' : '';
$welcomeMenuOpts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
}
$commands = $config['commands'] ?? [];
$menus = $config['menus'] ?? [];
$flows = $config['flows'] ?? [];
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
$aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : '';
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
@@ -3347,6 +3347,7 @@ HTML;
<div class="card-h">👥 Configuración por Categoría</div>
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define qué menú de bienvenida ve cada categoría de usuario cuando escribe por primera vez o sin contexto activo.</p>
{$categoryTabHtml}
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
</div>
<!-- ─── AI ───────────────────────────────────────────────────────────── -->
@@ -3491,6 +3492,28 @@ HTML;
const CONFIG = {$botConfigJson};
const ENDPOINTS = {$botEndpointsJson};
function savePerType() {
const cid = document.querySelector('input[name="company_id"]').value;
const fd = new FormData();
fd.set('company_id', cid);
[1, 2, 3].forEach(function(cat) {
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
});
var btn = document.getElementById('savePerTypeBtn');
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
fetch('/admin/bot-config/save-per-type', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
if (btn) { btn.textContent = '💾 Guardar categorías'; btn.disabled = false; }
alert(d.ok ? '✅ Categorías guardadas correctamente.' : '❌ Error al guardar: ' + JSON.stringify(d));
})
.catch(function(e) {
if (btn) { btn.textContent = '💾 Guardar categorías'; btn.disabled = false; }
alert('❌ Error de red: ' + e.message);
});
}
function switchTab(tab, btn) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active'));
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
class DbSessionHandler implements SessionHandlerInterface
{
private const TTL = 86400 * 30; // 30 days
public function open($path, $name): bool { return true; }
public function close(): bool { return true; }
public function read($id): string
{
$stmt = db()->prepare("SELECT data FROM sessions WHERE id = ? AND updated_at > DATE_SUB(NOW(), INTERVAL " . self::TTL . " SECOND)");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? $row['data'] : '';
}
public function write($id, $data): bool
{
db()->prepare("INSERT INTO sessions (id, data) VALUES (?, ?) ON DUPLICATE KEY UPDATE data = VALUES(data), updated_at = NOW()")
->execute([$id, $data]);
return true;
}
public function destroy($id): bool
{
db()->prepare("DELETE FROM sessions WHERE id = ?")->execute([$id]);
return true;
}
public function gc($max_lifetime): int
{
$stmt = db()->prepare("DELETE FROM sessions WHERE updated_at < DATE_SUB(NOW(), INTERVAL ? SECOND)");
$stmt->execute([self::TTL]);
return (int)$stmt->rowCount();
}
}
+4 -1
View File
@@ -1,13 +1,16 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/DbSessionHandler.php';
class SessionAuth
{
public static function start(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_set_save_handler(new DbSessionHandler(), true);
session_set_cookie_params([
'lifetime' => 0,
'lifetime' => 86400 * 30, // 30 days — survives deploys
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
+1
View File
@@ -0,0 +1 @@
max_input_vars = 5000
+56 -13
View File
@@ -320,6 +320,10 @@ $routes = [
$companyId = (int)($_POST['company_id'] ?? 0);
if ($companyId === 0) { header('Location: /admin/bot-config?msg=error'); exit; }
// Load existing DB config so keys not in the form (per_type, welcome_menu, etc.) are preserved
$existingCompany = CompanyRepository::findById($companyId);
$existingConfig = json_decode($existingCompany['config_json'] ?? '{}', true) ?: [];
$config = [];
// Commands
$keywords = $_POST['cmd_keyword'] ?? [];
@@ -536,20 +540,10 @@ $routes = [
$geminiModel = trim($_POST['gemini_model'] ?? '');
if ($geminiModel !== '') $config['gemini_model'] = $geminiModel;
// Per-category menus — merge sobre la config existente para no perder flows/commands por categoría
$perTypeMenus = $_POST['per_type_menu'] ?? [];
$perType = $config['per_type'] ?? [];
foreach ([1, 2, 3] as $cat) {
$mk = trim($perTypeMenus[$cat] ?? '');
$key = (string)$cat;
if ($mk !== '') {
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]);
} elseif (isset($perType[$key]['greeting_menu'])) {
unset($perType[$key]['greeting_menu']);
if (empty($perType[$key])) unset($perType[$key]);
}
// per_type is managed exclusively via /admin/bot-config/save-per-type — copy as-is from DB
if (isset($existingConfig['per_type'])) {
$config['per_type'] = $existingConfig['per_type'];
}
$config['per_type'] = $perType;
// General
$greeting = trim($_POST['greeting'] ?? '');
@@ -565,11 +559,60 @@ $routes = [
$welcomeMenu = trim($_POST['welcome_menu'] ?? '');
if ($welcomeMenu !== '') $config['welcome_menu'] = $welcomeMenu;
// Preserve any DB keys the form doesn't explicitly manage (welcome_menu, per_type, etc.)
$formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature',
'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model',
'ai_for_media', 'greeting', 'fallback', 'approval_webhook',
'ignore_prefixes', 'welcome_menu', 'per_type'];
foreach ($existingConfig as $k => $v) {
if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) {
$config[$k] = $v;
}
// Also preserve managed keys that POST didn't provide (e.g. truncated by max_input_vars)
if (in_array($k, ['welcome_menu', 'per_type'], true) && !isset($config[$k])) {
$config[$k] = $v;
}
}
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
header('Location: /admin/bot-config?id=' . $companyId . '&msg=saved');
exit;
})()],
// ─── Admin: guardar per_type por categoría (endpoint separado) ───────────
['POST', '/admin/bot-config/save-per-type', fn() => (function () {
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
if ($companyId === 0) { echo json_encode(['ok' => false]); exit; }
$company = CompanyRepository::findById($companyId);
if (!$company) { echo json_encode(['ok' => false]); exit; }
$config = json_decode($company['config_json'] ?? '{}', true) ?: [];
$rawPt = $config['per_type'] ?? [];
$perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0)
? $rawPt : [];
$perTypeMenus = $_POST['per_type_menu'] ?? [];
foreach ([1, 2, 3] as $cat) {
$mk = trim($perTypeMenus[$cat] ?? '');
$key = (string)$cat;
if ($mk !== '') {
$perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]);
} else {
unset($perType[$key]['greeting_menu']);
if (empty($perType[$key])) unset($perType[$key]);
}
}
$config['per_type'] = empty($perType) ? new stdClass() : $perType;
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
echo json_encode(['ok' => true]);
exit;
})()],
// ─── Admin: configuración general ──────────────────────────────────────
['GET', '/admin/settings', fn() => DashboardController::settings()],
+17 -4
View File
@@ -136,6 +136,14 @@ class NormalBot
$group = $col['meta_group'] ?? '';
$key = $col['meta_key'] ?? '';
$nextNode = $col['next_node'] ?? null;
$validIds = $col['__valid_ids'] ?? null;
if ($validIds !== null && !in_array(trim($input), $validIds, true)) {
return self::sendText(
'⚠️ Por favor selecciona una opción válida de la lista.',
$context['from'], $company
);
}
$formData = $meta[$group] ?? [];
$formData[$key] = trim($input);
@@ -182,10 +190,12 @@ class NormalBot
);
}
$valueField = $flow['value_field'] ?? 'id';
$meta['collecting'] = [
'meta_group' => $flow['meta_group'] ?? '',
'meta_key' => $flow['meta_key'] ?? '',
'next_node' => $flow['next_node'] ?? null,
'meta_group' => $flow['meta_group'] ?? '',
'meta_key' => $flow['meta_key'] ?? '',
'next_node' => $flow['next_node'] ?? null,
'__valid_ids' => array_column($items, $valueField),
];
ConversationContext::updateMetadata($ctxId, $meta);
ConversationContext::updateNode($ctxId, 'collecting');
@@ -783,6 +793,9 @@ class NormalBot
} elseif ($dateMode === 'current_month') {
$extraQuery['fecha_desde'] = date('Y-m-01');
$extraQuery['fecha_hasta'] = date('Y-m-d');
} elseif ($dateMode === 'last_month') {
$extraQuery['fecha_desde'] = date('Y-m-01', strtotime('first day of last month'));
$extraQuery['fecha_hasta'] = date('Y-m-t', strtotime('last day of last month'));
}
$extraQuery['telefono'] = $context['from'];
$extraQuery['nombre'] = $context['name'] ?? '';
@@ -871,7 +884,7 @@ class NormalBot
WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName);
ConversationContext::reset($ctxId);
return self::sendText('✅ Reporte enviado.' . $nav, $context['from'], $company);
return self::sendText('✅ Reporte generado.' . $nav, $context['from'], $company);
}
// ── Public helpers ───────────────────────────────────────────────────────