commit 202a263e250755d82e456a2028f3f6649ac1d9a8 Author: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu Jun 4 13:35:23 2026 -0500 feat: bot WhatsApp Business API — Palmas360 inicial diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9385fe8 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# ─── Aplicación ─────────────────────────────────────────────────────────────── +APP_ENV=local +APP_URL=https://app.palmas360.com + +# ─── JWT ─────────────────────────────────────────────────────────────────────── +# Secreto usado para firmar y verificar los tokens Bearer +JWT_SECRET=cambia_esto_por_un_secreto_seguro_minimo_32_chars + +# ─── WhatsApp Business API (Meta) ──────────────────────────────────────────── +# Token de verificación del webhook (lo defines tú en Meta for Developers) +WHATSAPP_VERIFY_TOKEN=mi_token_de_verificacion_secreto + +# App Secret de la aplicación en Meta for Developers +# (Usado para validar la firma HMAC-SHA256 de cada POST entrante) +WHATSAPP_APP_SECRET=app_secret_de_meta_for_developers + +# Token de acceso permanente de WhatsApp Business API +WHATSAPP_ACCESS_TOKEN=EAAxxxxxxxxx... + +# ID del número de teléfono de WhatsApp Business +WHATSAPP_PHONE_NUMBER_ID=1234567890 + +# ID de la cuenta de WhatsApp Business +WHATSAPP_BUSINESS_ACCOUNT_ID=0987654321 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d7a750 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +storage/ +*.log +.DS_Store diff --git a/WhatsApp Image 2026-06-04 at 10.04.54 (2).jpeg b/WhatsApp Image 2026-06-04 at 10.04.54 (2).jpeg new file mode 100644 index 0000000..b868a81 Binary files /dev/null and b/WhatsApp Image 2026-06-04 at 10.04.54 (2).jpeg differ diff --git a/admin/DashboardController.php b/admin/DashboardController.php new file mode 100644 index 0000000..9692bd6 --- /dev/null +++ b/admin/DashboardController.php @@ -0,0 +1,590 @@ + 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0]; + $logs = []; + $total = 0; + } + + $pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1; + self::render(compact('stats', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user')); + } + + // ─── GET /admin/live ──────────────────────────────────────────────────── + + public static function live(): void + { + SessionAuth::require(); + $user = SessionAuth::user(); + $userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8'); + + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + echo << + + + + + Live — Palmas360 + + + +
+
+

Palmas360 Live Feed

+
+
+ ← Dashboard + 👤 {$userName} + Salir +
+
+
+ + Conectado — actualizando cada 3s + + 0 eventos +
+

Esperando eventos en tiempo real...

+ + + + + + +HTML; + exit; + } + + // ─── GET /admin/webhook/stream?after=N ─────────────────────────────────── + + public static function stream(): void + { + SessionAuth::require(); + $afterId = max(0, (int)($_GET['after'] ?? 0)); + $isInit = isset($_GET['init']); + + try { + if ($isInit) { + // En init devolvemos solo el último ID (sin datos) para anclar + $stmt = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1'); + $row = $stmt->fetch(); + $afterId = $row ? (int)$row['id'] : 0; + // Devolvemos array vacío — solo queremos anclar lastId en el cliente + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([]); + exit; + } + + $stmt = db()->prepare(" + SELECT id, event_field, from_number, contact_name, + message_type, message_preview, received_at + FROM webhook_logs + WHERE id > ? + ORDER BY id ASC + LIMIT 50 + "); + $stmt->execute([$afterId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (\PDOException $e) { + header('Content-Type: application/json; charset=utf-8'); + echo '[]'; + exit; + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($rows, JSON_UNESCAPED_UNICODE); + exit; + } + + // ─── GET /admin/webhook/raw?id=N ───────────────────────────────────────── + + public static function getRaw(): void + { + SessionAuth::require(); + $id = max(0, (int)($_GET['id'] ?? 0)); + if ($id === 0) { + jsonResponse(400, ['error' => 'ID inválido']); + } + try { + $stmt = db()->prepare('SELECT raw_payload FROM webhook_logs WHERE id = ? LIMIT 1'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + } catch (\PDOException $e) { + jsonResponse(500, ['error' => 'Error de base de datos']); + } + if (!$row) { + jsonResponse(404, ['error' => 'Registro no encontrado']); + } + header('Content-Type: application/json; charset=utf-8'); + echo $row['raw_payload']; + exit; + } + + // ─── Consultas ──────────────────────────────────────────────────────────── + + private static function getStats(PDO $db): array + { + $stmt = $db->query(" + SELECT + COUNT(*) AS total, + COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs, + COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media, + COALESCE(SUM(event_field='statuses'),0) AS statuses + FROM webhook_logs + WHERE DATE(received_at) = CURDATE() + "); + return $stmt->fetch() ?: ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0]; + } + + private static function getLogs(PDO $db, int $page, string $filter, string $search, string $date): array + { + $where = ['DATE(received_at) = ?']; + $params = [$date]; + + if ($filter !== '') { + $where[] = 'event_field = ?'; + $params[] = $filter; + } + if ($search !== '') { + $where[] = '(from_number LIKE ? OR contact_name LIKE ? OR message_preview LIKE ?)'; + $like = '%' . $search . '%'; + array_push($params, $like, $like, $like); + } + + $w = implode(' AND ', $where); + + $cnt = $db->prepare("SELECT COUNT(*) FROM webhook_logs WHERE $w"); + $cnt->execute($params); + $total = (int)$cnt->fetchColumn(); + + $limit = self::PER_PAGE; + $offset = ($page - 1) * $limit; + + $stmt = $db->prepare(" + SELECT id, event_field, from_number, contact_name, + message_type, message_preview, received_at + FROM webhook_logs + WHERE $w + ORDER BY received_at DESC + LIMIT $limit OFFSET $offset + "); + $stmt->execute($params); + return [$stmt->fetchAll(), $total]; + } + + // ─── Helpers de vista ───────────────────────────────────────────────────── + + private static function h(mixed $v): string + { + return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private static function typeLabel(string $field, string $type): string + { + if ($field === 'statuses') { + $cls = match ($type) { + 'sent' => 'badge-blue', + 'delivered' => 'badge-green', + 'read' => 'badge-teal', + 'failed' => 'badge-red', + default => 'badge-gray', + }; + return '' . self::h($type) . ''; + } + return match ($type) { + 'text' => '💬 Texto', + 'image' => '🖼️ Imagen', + 'audio' => '🎵 Audio', + 'video' => '🎥 Video', + 'document' => '📄 Documento', + 'location' => '📍 Ubicación', + 'interactive' => '🔘 Interactivo', + 'button' => '🔲 Botón', + default => self::h($type), + }; + } + + // ─── Render ─────────────────────────────────────────────────────────────── + + private static function render(array $v): void + { + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + + // Variables para el heredoc + $userName = self::h($v['user']['name'] ?? 'Admin'); + $dateVal = self::h($v['date']); + $maxDate = date('Y-m-d'); + $filterVal = self::h($v['filter']); + $searchVal = self::h($v['search']); + $totalStr = number_format((int)$v['total']); + $selMessages = $v['filter'] === 'messages' ? 'selected' : ''; + $selStatuses = $v['filter'] === 'statuses' ? 'selected' : ''; + $sTotal = (int)$v['stats']['total']; + $sMsgs = (int)$v['stats']['msgs']; + $sMedia = (int)$v['stats']['media']; + $sStatuses = (int)$v['stats']['statuses']; + + // Filas de la tabla + $rows = ''; + if (empty($v['logs'])) { + $rows = 'Sin eventos para esta fecha.'; + } else { + foreach ($v['logs'] as $log) { + $id = (int)$log['id']; + $time = self::h(substr($log['received_at'] ?? '', 11, 8)); + $from = self::h($log['from_number'] ?? '—'); + $name = self::h($log['contact_name'] ?? '—'); + $type = self::typeLabel($log['event_field'] ?? '', $log['message_type'] ?? ''); + $preview = self::h(mb_substr($log['message_preview'] ?? '—', 0, 70)); + $rows .= "" + . "#{$id}" + . "{$time}" + . "{$from}" + . "{$name}" + . "{$type}" + . "{$preview}" + . "" + . "\n"; + } + } + + // Paginación + $pager = ''; + if ($v['pages'] > 1) { + $pager = '
'; + for ($i = 1; $i <= min((int)$v['pages'], 20); $i++) { + $qs = '?page=' . $i + . '&type=' . urlencode($v['filter']) + . '&search=' . urlencode($v['search']) + . '&date=' . urlencode($v['date']); + $active = $i === (int)$v['page'] ? ' active' : ''; + $pager .= "{$i}"; + } + $pager .= '
'; + } + + echo << + + + + + Admin — Palmas360 + + + + +
+
+

Palmas360 Palmas360  ·  Admin

+ Somos19D +
+
+ 👤 {$userName} + Salir +
+
+ +
+
{$sTotal}
📡 Total hoy
+
{$sMsgs}
💬 Mensajes texto
+
{$sMedia}
📎 Multimedia
+
{$sStatuses}
📊 Estados
+
+ +
+
+ + + + + {$totalStr} resultado(s) +
+
+ +
+ + + + + + + + + + + + + {$rows} +
IDHoraNúmeroNombreTipoPreview
+ {$pager} +
+ + + + + + + + +HTML; + exit; + } +} diff --git a/admin/LoginController.php b/admin/LoginController.php new file mode 100644 index 0000000..fe705ed --- /dev/null +++ b/admin/LoginController.php @@ -0,0 +1,128 @@ +prepare('SELECT id, name, email, password FROM users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + $user = $stmt->fetch(); + } catch (\PDOException $e) { + $_SESSION['login_error'] = 'Error de base de datos.'; + header('Location: /login'); + exit; + } + + if (!$user || !password_verify($password, $user['password'])) { + sleep(1); // freno básico a fuerza bruta + $_SESSION['login_error'] = 'Correo o contraseña incorrectos.'; + header('Location: /login'); + exit; + } + + SessionAuth::login($user); + header('Location: /admin/dashboard'); + exit; + } + + public static function logout(): void + { + SessionAuth::logout(); + header('Location: /login'); + exit; + } + + // ─── Vista ─────────────────────────────────────────────────────────────── + + private static function renderForm(string $token, string $error): void + { + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + $errorHtml = $error + ? '
' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '
' + : ''; + echo << + + + + + Ingresar — Palmas360 + + + +
+
+
Palmas360
+

Palmas360

+

Panel de administración — Somos19D

+
+
+ {$errorHtml} +
+ + + + + + +
+
+
© 2025 Somos19D
+
+ + + + +HTML; + exit; + } +} diff --git a/admin/v1/WpWebhook.php b/admin/v1/WpWebhook.php new file mode 100644 index 0000000..0fcef40 --- /dev/null +++ b/admin/v1/WpWebhook.php @@ -0,0 +1,376 @@ + 'Verificación fallida'], JSON_UNESCAPED_UNICODE); + exit; + } + + // ─── POST: Recepción de eventos ─────────────────────────────────────────── + + public static function receive(): void + { + // 1. Leer body raw + $rawBody = file_get_contents('php://input'); + if ($rawBody === false || $rawBody === '') { + self::respond(400, ['error' => 'Body vacío']); + } + + // 2. Guardar raw body para los handlers + self::$currentRaw = $rawBody; + + // 3. Verificar firma HMAC-SHA256 de Meta + self::verifySignature($rawBody); + + // 4. Decodificar JSON + $payload = json_decode($rawBody, true); + if (!is_array($payload)) { + self::respond(400, ['error' => 'JSON inválido']); + } + + // 5. Procesar ANTES de responder + self::processEvent($payload, $rawBody); + + // 6. Responder 200 a Meta (menos de 20 seg) + self::respond(200, ['status' => 'received']); + } + + // ─── Procesamiento de eventos ───────────────────────────────────────────── + + private static function processEvent(array $payload, string $raw): void + { + $object = $payload['object'] ?? ''; + + if ($object !== 'whatsapp_business_account') { + self::log('WARN', "Objeto desconocido: $object"); + return; + } + + $entries = $payload['entry'] ?? []; + foreach ($entries as $entry) { + $changes = $entry['changes'] ?? []; + foreach ($changes as $change) { + $field = $change['field'] ?? ''; + $value = $change['value'] ?? []; + + match ($field) { + 'messages', 'conversations' => self::handleMessages($value), + 'statuses' => self::handleStatuses($value), + default => self::log('INFO', "Campo no manejado: $field"), + }; + } + } + + // Persistir evento crudo para auditoría + self::saveRawEvent($raw); + } + + // ─── Mensajes entrantes ─────────────────────────────────────────────────── + + private static function handleMessages(array $value): void + { + $messages = $value['messages'] ?? []; + $contacts = $value['contacts'] ?? []; + $metadata = $value['metadata'] ?? []; + + $phoneNumberId = $metadata['phone_number_id'] ?? ''; + $displayPhone = $metadata['display_phone_number'] ?? ''; + + foreach ($messages as $msg) { + $from = $msg['from'] ?? ''; // Número del remitente + $msgId = $msg['id'] ?? ''; + $type = $msg['type'] ?? 'unknown'; + $ts = $msg['timestamp'] ?? time(); + + // Nombre del contacto (si existe) + $name = ''; + foreach ($contacts as $c) { + if (($c['wa_id'] ?? '') === $from) { + $name = $c['profile']['name'] ?? ''; + break; + } + } + + $context = [ + 'from' => $from, + 'name' => $name, + 'message_id' => $msgId, + 'type' => $type, + 'timestamp' => $ts, + 'phone_number_id' => $phoneNumberId, + 'display_phone' => $displayPhone, + ]; + + match ($type) { + 'text' => self::handleText($msg, $context), + 'image' => self::handleMedia($msg, $context, 'image'), + 'audio' => self::handleMedia($msg, $context, 'audio'), + 'video' => self::handleMedia($msg, $context, 'video'), + 'document' => self::handleMedia($msg, $context, 'document'), + 'sticker' => self::handleMedia($msg, $context, 'sticker'), + 'location' => self::handleLocation($msg, $context), + 'interactive' => self::handleInteractive($msg, $context), + 'button' => self::handleButton($msg, $context), + 'reaction' => self::handleReaction($msg, $context), + default => self::log('INFO', "Tipo de mensaje no manejado: $type | from=$from"), + }; + } + + // Estados que pueden venir dentro del mismo field 'messages' + if (!empty($value['statuses'])) { + self::handleStatuses($value); + } + } + + private static function handleText(array $msg, array $ctx): void + { + $body = $msg['text']['body'] ?? ''; + self::log('MSG', "[TEXT] {$ctx['from']} ({$ctx['name']}): $body"); + self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'text', $body); + self::saveConversation($ctx, $body); + + // ─── Aquí conectas con tu lógica de bot ────────────────────────────── + // Ejemplo: BotHandler::process($ctx, $body); + } + + private static function handleMedia(array $msg, array $ctx, string $type): void + { + $data = $msg[$type] ?? []; + $mediaId = $data['id'] ?? ''; + $mime = $data['mime_type'] ?? ''; + $caption = $data['caption'] ?? ''; + $preview = $caption ?: "[$type id:$mediaId]"; + self::log('MSG', "[" . strtoupper($type) . "] {$ctx['from']} | id=$mediaId mime=$mime caption=$caption"); + self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], $type, $preview); + self::saveConversation($ctx, $preview, $mediaId); + } + + private static function handleLocation(array $msg, array $ctx): void + { + $lat = $msg['location']['latitude'] ?? ''; + $lng = $msg['location']['longitude'] ?? ''; + $name = $msg['location']['name'] ?? ''; + $preview = "lat:$lat lng:$lng" . ($name ? " ($name)" : ''); + self::log('MSG', "[LOCATION] {$ctx['from']} | lat=$lat lng=$lng name=$name"); + self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'location', $preview); + self::saveConversation($ctx, $preview); + } + + private static function handleInteractive(array $msg, array $ctx): void + { + $iType = $msg['interactive']['type'] ?? ''; + $reply = match ($iType) { + 'button_reply' => $msg['interactive']['button_reply'] ?? [], + 'list_reply' => $msg['interactive']['list_reply'] ?? [], + default => [], + }; + $preview = $iType . ': ' . json_encode($reply); + self::log('MSG', "[INTERACTIVE/$iType] {$ctx['from']} | reply=" . json_encode($reply)); + self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'interactive', $preview); + self::saveConversation($ctx, $preview); + } + + private static function handleButton(array $msg, array $ctx): void + { + $text = $msg['button']['text'] ?? ''; + $payload = $msg['button']['payload'] ?? ''; + $preview = "$text | $payload"; + self::log('MSG', "[BUTTON] {$ctx['from']} | text=$text payload=$payload"); + self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'button', $preview); + self::saveConversation($ctx, $preview); + } + + private static function handleReaction(array $msg, array $ctx): void + { + $emoji = $msg['reaction']['emoji'] ?? ''; + $reactTo = $msg['reaction']['message_id'] ?? ''; + $preview = "emoji:$emoji replyTo:$reactTo"; + self::log('MSG', "[REACTION] {$ctx['from']} ({$ctx['name']}) | emoji=$emoji msg=$reactTo"); + self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'reaction', $preview); + self::saveConversation($ctx, $preview); + } + + // ─── Estados de mensajes enviados ──────────────────────────────────────── + + private static function handleStatuses(array $value): void + { + $statuses = $value['statuses'] ?? []; + foreach ($statuses as $s) { + $id = $s['id'] ?? ''; + $status = $s['status'] ?? ''; // sent | delivered | read | failed + $recipient = $s['recipient_id'] ?? ''; + $ts = $s['timestamp'] ?? ''; + + if ($status === 'failed') { + $errors = $s['errors'] ?? []; + self::log('ERROR', "[STATUS:failed] msg=$id recipient=$recipient errors=" . json_encode($errors)); + } else { + self::log('INFO', "[STATUS:$status] msg=$id recipient=$recipient ts=$ts"); + } + self::saveWebhookLog('statuses', $recipient, '', $status, "msg_id:$id"); + } + } + + // ─── Firma HMAC-SHA256 de Meta ──────────────────────────────────────────── + + private static function verifySignature(string $rawBody): void + { + $appSecret = env('WHATSAPP_APP_SECRET', ''); + if ($appSecret === '') { + // En desarrollo puedes omitir esta validación; en producción es obligatoria + self::log('WARN', 'WHATSAPP_APP_SECRET no configurado. Saltando verificación de firma.'); + return; + } + + $sigHeader = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''; + if (!str_starts_with($sigHeader, 'sha256=')) { + self::respond(401, ['error' => 'Firma ausente']); + } + + $received = substr($sigHeader, 7); + $expected = hash_hmac('sha256', $rawBody, $appSecret); + + if (!hash_equals($expected, $received)) { + self::log('WARN', 'Firma HMAC inválida. Posible payload adulterado.'); + self::respond(401, ['error' => 'Firma inválida']); + } + } + + // ─── Utilidades ─────────────────────────────────────────────────────────── + + // ─── Guardar en base de datos ───────────────────────────────────────────── + + private static function saveConversation(array $ctx, string $content, ?string $mediaId = null): void + { + try { + $stmt = db()->prepare(" + INSERT IGNORE INTO conversations + (message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp) + VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?) + "); + $stmt->execute([ + $ctx['message_id'], + $ctx['from'], + $ctx['name'], + $ctx['type'], + mb_substr($content, 0, 1000), + $mediaId, + $ctx['timestamp'], + ]); + if ($stmt->rowCount() > 0) { + $convId = (int) db()->lastInsertId(); + self::saveNotification($convId, $ctx['from'], $content); + } + } catch (\PDOException $e) { + self::log('ERROR', 'DB saveConversation: ' . $e->getMessage()); + } + } + + private static function saveNotification(int $referenceId, string $phone, string $preview): void + { + try { + $stmt = db()->prepare(" + INSERT INTO notifications (type, reference_id, phone_number, message) + VALUES ('new_message', ?, ?, ?) + "); + $stmt->execute([$referenceId, $phone, mb_substr($preview, 0, 255)]); + } catch (\PDOException $e) { + self::log('ERROR', 'DB saveNotification: ' . $e->getMessage()); + } + } + + private static function saveWebhookLog( + string $field, + string $from, + string $name, + string $type, + string $preview + ): void { + try { + $stmt = db()->prepare(" + INSERT INTO webhook_logs + (event_field, from_number, contact_name, message_type, message_preview, raw_payload) + VALUES (?, ?, ?, ?, ?, ?) + "); + $stmt->execute([$field, $from, $name, $type, mb_substr($preview, 0, 500), self::$currentRaw]); + } catch (\PDOException $e) { + self::log('ERROR', 'DB saveWebhookLog: ' . $e->getMessage()); + } + } + + /** + * Guarda el evento crudo en storage/events/ para auditoría. + */ + private static function saveRawEvent(string $raw): void + { + $dir = dirname(__DIR__, 2) . '/storage/events'; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + $file = $dir . '/' . date('Y-m-d') . '.log'; + $line = '[' . date('Y-m-d H:i:s') . '] ' . $raw . PHP_EOL; + file_put_contents($file, $line, FILE_APPEND | LOCK_EX); + } + + private static function log(string $level, string $message): void + { + $dir = dirname(__DIR__, 2) . '/storage/logs'; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + $file = $dir . '/webhook-' . date('Y-m-d') . '.log'; + $line = '[' . date('Y-m-d H:i:s') . "] [$level] $message" . PHP_EOL; + file_put_contents($file, $line, FILE_APPEND | LOCK_EX); + // También a stderr en desarrollo + if (env('APP_ENV', 'production') === 'local') { + error_log($line); + } + } + + private static function respond(int $code, array $body): never + { + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($body, JSON_UNESCAPED_UNICODE); + exit; + } +} diff --git a/config/db.php b/config/db.php new file mode 100644 index 0000000..4a60b94 --- /dev/null +++ b/config/db.php @@ -0,0 +1,36 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } catch (\PDOException $e) { + http_response_code(503); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['error' => 'Error de conexión a la base de datos.'], JSON_UNESCAPED_UNICODE); + exit; + } + + return $pdo; +} diff --git a/config/env.php b/config/env.php new file mode 100644 index 0000000..cf1cb2b --- /dev/null +++ b/config/env.php @@ -0,0 +1,38 @@ + + * El secreto se toma de la variable de entorno JWT_SECRET. + */ +class Auth +{ + /** + * Verifica el Bearer token. Termina con 401 si es inválido. + * Devuelve el payload decodificado si es válido. + */ + public static function requireBearer(): array + { + $header = $_SERVER['HTTP_AUTHORIZATION'] + ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] + ?? ''; + + if (!str_starts_with($header, 'Bearer ')) { + self::abort(401, 'Token de autorización requerido.'); + } + + $token = substr($header, 7); + return self::verifyJwt($token); + } + + // ─── JWT HS256 ──────────────────────────────────────────────────────────── + + private static function verifyJwt(string $token): array + { + $parts = explode('.', $token); + if (count($parts) !== 3) { + self::abort(401, 'Token malformado.'); + } + + [$b64Header, $b64Payload, $b64Sig] = $parts; + + // Verificar firma + $secret = env('JWT_SECRET', ''); + if ($secret === '') { + self::abort(500, 'JWT_SECRET no configurado.'); + } + + $data = "$b64Header.$b64Payload"; + $expected = self::base64UrlEncode( + hash_hmac('sha256', $data, $secret, true) + ); + + // Comparación segura contra timing attacks + if (!hash_equals($expected, $b64Sig)) { + self::abort(401, 'Firma JWT inválida.'); + } + + // Decodificar payload + $payload = json_decode(self::base64UrlDecode($b64Payload), true); + if (!is_array($payload)) { + self::abort(401, 'Payload JWT inválido.'); + } + + // Validar expiración si existe + if (isset($payload['exp']) && $payload['exp'] < time()) { + self::abort(401, 'Token expirado.'); + } + + return $payload; + } + + private static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private static function base64UrlDecode(string $data): string + { + return base64_decode(strtr($data, '-_', '+/')); + } + + private static function abort(int $code, string $message): never + { + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE); + exit; + } +} diff --git a/middleware/SessionAuth.php b/middleware/SessionAuth.php new file mode 100644 index 0000000..bc0d036 --- /dev/null +++ b/middleware/SessionAuth.php @@ -0,0 +1,75 @@ + 0, + 'path' => '/', + 'secure' => isset($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + } + } + + /** Redirige a /login si no hay sesión activa. */ + public static function require(): void + { + self::start(); + if (empty($_SESSION['user_id'])) { + header('Location: /login'); + exit; + } + } + + public static function login(array $user): void + { + self::start(); + session_regenerate_id(true); + $_SESSION['user_id'] = $user['id']; + $_SESSION['user'] = [ + 'id' => $user['id'], + 'name' => $user['name'], + 'email' => $user['email'], + ]; + } + + public static function logout(): void + { + self::start(); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']); + } + session_destroy(); + } + + public static function user(): array + { + return $_SESSION['user'] ?? []; + } + + public static function csrfToken(): string + { + self::start(); + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + public static function validateCsrf(): void + { + $token = $_POST['_token'] ?? ''; + if (!isset($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $token)) { + http_response_code(403); + exit('Token CSRF inválido.'); + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..23d848d --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "bot-palmas360", + "version": "1.0.0", + "description": "WhatsApp Business API webhook — Somos19D / Palmas360", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.19.2", + "dotenv": "^16.4.5", + "jsonwebtoken": "^9.0.2", + "axios": "^1.7.2" + }, + "devDependencies": { + "nodemon": "^3.1.4" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..35600da --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,9 @@ +Options -Indexes +RewriteEngine On + +# Permitir acceso directo a archivos y carpetas reales +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# Todo lo demás pasa por index.php +RewriteRule ^ index.php [QSA,L] diff --git a/public/condiciones-servicio.html b/public/condiciones-servicio.html new file mode 100644 index 0000000..2c9f9d6 --- /dev/null +++ b/public/condiciones-servicio.html @@ -0,0 +1,170 @@ + + + + + + Condiciones del Servicio — Somos19D + + + + +
📄 Condiciones Oficiales del Servicio · Somos19D
+ +
+
+ NIT: 901677603-5 +

Condiciones del Servicio

+

Somos19D — Empresa jurídicamente constituida en Colombia
Vigente desde el 1 de enero de 2025

+
+
+ +
+

1. Identificación del prestador

+

Somos19D, empresa jurídicamente constituida en Colombia, con NIT 901677603-5, es responsable de la prestación de los servicios descritos en estas condiciones. Nuestros canales de atención incluyen WhatsApp y plataformas digitales.

+
+ +
+

2. Aceptación de las condiciones

+

Al usar nuestros servicios —ya sea a través de WhatsApp, nuestro sitio web u otros canales— aceptas íntegramente estas condiciones. Si no estás de acuerdo, debes abstenerte de utilizar el servicio.

+
+ +
+

3. WhatsApp Business Platform

+

Somos19D utiliza la WhatsApp Business Platform de Meta Platforms, Inc. para prestar servicios de atención y comunicación. Al usar este canal:

+
    +
  • Aceptas las Condiciones del Servicio de WhatsApp Business de Meta.
  • +
  • Los mensajes están sujetos a las políticas de uso aceptable de WhatsApp.
  • +
  • Queda prohibido el uso del canal para spam, mensajes masivos no autorizados o contenido ilegal.
  • +
  • Somos19D puede bloquear usuarios que infrinjan las políticas de Meta o las presentes condiciones.
  • +
+
📱 Al escribirnos por WhatsApp confirmas que has leído y aceptado estas condiciones.
+
+ +
+

4. Descripción del servicio

+

Somos19D ofrece servicios digitales y de información a través de múltiples canales incluyendo WhatsApp Business, sitio web y otros medios digitales. Los detalles específicos de cada servicio serán informados al usuario antes de su contratación, incluyendo tarifas, plazos y condiciones particulares.

+
+ +
+

5. Obligaciones del usuario

+
    +
  • Proporcionar información veraz, exacta y actualizada
  • +
  • Usar el servicio únicamente para fines lícitos
  • +
  • No suplantar la identidad de terceros
  • +
  • No interferir en el funcionamiento del servicio
  • +
  • Respetar los derechos de propiedad intelectual de Somos19D
  • +
  • Mantener la confidencialidad de sus credenciales de acceso
  • +
+
+ +
+

6. Obligaciones de Somos19D

+
    +
  • Prestar el servicio con la calidad y oportunidad acordada
  • +
  • Proteger los datos personales del usuario conforme a la ley
  • +
  • Informar oportunamente sobre cambios en el servicio o en estas condiciones
  • +
  • Atender las reclamaciones del usuario en tiempos razonables
  • +
+
+ +
+

7. Tarifas y pagos

+

Los precios del servicio serán comunicados de forma clara antes de cada transacción. Somos19D se reserva el derecho de modificar sus tarifas, notificando al usuario con antelación razonable. Los pagos realizados no son reembolsables salvo disposición expresa contraria.

+
+ +
+

8. Propiedad intelectual

+

Todo el contenido, diseño, marca, textos y elementos digitales de Somos19D son propiedad exclusiva de la empresa. Está prohibida su reproducción, distribución o uso sin autorización escrita previa.

+
+ +
+

9. Limitación de responsabilidad

+

Somos19D no será responsable por:

+
    +
  • Interrupciones del servicio por causas de fuerza mayor o fallas de terceros
  • +
  • Daños derivados del uso indebido del servicio por parte del usuario
  • +
  • Pérdidas de datos por causas ajenas a nuestra infraestructura
  • +
  • Acciones de terceros no autorizados
  • +
+
+ +
+

10. Suspensión del servicio

+

Somos19D podrá suspender o terminar el servicio a un usuario si este incumple estas condiciones, proporciona información falsa o realiza actividades que perjudiquen a la empresa o a otros usuarios. Se notificará al usuario antes de la suspensión cuando sea posible.

+
+ +
+

11. Legislación y jurisdicción

+

Estas condiciones se rigen por las leyes de la República de Colombia. Cualquier controversia se resolverá conforme a la normativa colombiana vigente. Para consumidores aplican los mecanismos de protección al consumidor establecidos en la Ley 1480 de 2011.

+
📌 Ante cualquier disputa, puedes contactar a la Superintendencia de Industria y Comercio (SIC) o a la Defensoría del Consumidor.
+
+ +
+

12. Modificaciones

+

Podemos actualizar estas condiciones en cualquier momento. Los cambios se comunicarán por WhatsApp u otros canales oficiales con al menos 5 días de anticipación. El uso continuo del servicio implica la aceptación de las nuevas condiciones.

+
+ +
+

13. Contacto oficial

+

Para dudas, reclamaciones o ejercicio de derechos, comunícate con Somos19D a través de nuestros canales oficiales de WhatsApp o correo electrónico.

+
+ +
+ +
+ + + \ No newline at end of file diff --git a/public/eliminacion-datos-usuario.html b/public/eliminacion-datos-usuario.html new file mode 100644 index 0000000..798a9e2 --- /dev/null +++ b/public/eliminacion-datos-usuario.html @@ -0,0 +1,179 @@ + + + + + + Eliminación de Datos — Somos19D + + + + +
🗑️ Solicitud de Eliminación de Datos · Somos19D
+ +
+
+ NIT: 901677603-5 +

Eliminación de Datos del Usuario

+

Somos19D — Tu privacidad es un derecho.
Ejercelo de forma sencilla y sin costo.

+
+
+ +
+

¿Por qué existe este derecho?

+

La Ley 1581 de 2012 (Colombia) y el Habeas Data te otorgan el derecho de solicitar la supresión de tus datos personales cuando ya no son necesarios, cuando retiras tu consentimiento o cuando el tratamiento infringe la ley. Somos19D (NIT 901677603-5) está obligada a respetar y tramitar esta solicitud.

+
+ +
+

¿Cuándo puedes pedirlo?

+
    +
  • Cuando los datos ya no son necesarios para el servicio contratado
  • +
  • Cuando retiras tu consentimiento de tratamiento
  • +
  • Cuando consideras que el tratamiento viola la ley
  • +
  • Cuando el período de conservación acordado ha vencido
  • +
  • Cuando nunca autorizaste el uso de tus datos
  • +
+
+ +
+

Cómo hacer tu solicitud — paso a paso

+
+
+
1
+
Escríbenos por nuestros canales oficiales de WhatsApp o correo electrónico de Somos19D.
+
+
+
2
+
Indica: "Solicito eliminación de mis datos personales" y proporciona tu nombre completo y documento de identidad.
+
+
+
3
+
Menciona qué datos específicos deseas eliminar o si quieres la eliminación total de tu historial.
+
+
+
4
+
Confirmaremos la recepción de tu solicitud en máximo 2 días hábiles.
+
+
+
5
+
La eliminación se ejecutará en un plazo máximo de 15 días hábiles contados desde la confirmación.
+
+
+
+ +
+

Información necesaria en tu solicitud

+
    +
  • Nombre completo
  • +
  • Número de cédula o documento de identidad
  • +
  • Correo electrónico o número de WhatsApp asociado a la cuenta
  • +
  • Descripción de los datos que deseas eliminar
  • +
  • Motivo de la solicitud (opcional, pero útil)
  • +
+
+ +
+

Excepciones legales

+

Existen casos en los que no podemos eliminar algunos datos de forma inmediata:

+
    +
  • Cuando la ley exige conservarlos (ej. datos tributarios o contables)
  • +
  • Cuando son necesarios para cumplir un contrato vigente contigo
  • +
  • Cuando exista un proceso legal en curso
  • +
+

En estos casos, te informaremos el motivo y el plazo estimado de conservación.

+ ⚠️ Siempre te informaremos antes de denegar una solicitud +
+ +
+

¿Qué pasa después de la eliminación?

+
    +
  • Tus datos serán borrados permanentemente de nuestras bases de datos activas
  • +
  • Las copias de respaldo se eliminarán en el ciclo de actualización más próximo
  • +
  • Recibirás confirmación escrita (por WhatsApp o correo) una vez completado el proceso
  • +
+
+ +
+

Datos de WhatsApp y Meta

+

Si interactuaste con Somos19D a través de WhatsApp Business API, ten en cuenta que:

+
    +
  • Los datos de la conversación en WhatsApp están también sujetos a las políticas de Meta Platforms, Inc.
  • +
  • Para eliminar tus datos en Meta directamente, visita: Centro de ayuda de Meta.
  • +
  • Somos19D eliminará los registros internos de tus conversaciones y datos asociados dentro del plazo indicado.
  • +
  • Esta página puede ser presentada ante Meta como URL de instrucciones de eliminación de datos.
  • +
+ 📋 Esta URL es válida como "Data Deletion Instructions URL" para Meta/WhatsApp Business +
+ +
+

¿No recibes respuesta?

+

Si en 15 días hábiles no has recibido respuesta, puedes radicar una queja ante la Superintendencia de Industria y Comercio (SIC) de Colombia en www.sic.gov.co.

+
+ +
+ +
+ + + \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9c7c072 --- /dev/null +++ b/public/index.php @@ -0,0 +1,72 @@ + 'Página no encontrada']); + } + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + readfile($file); + exit; +} + +// ─── Router ────────────────────────────────────────────────────────────────── +$method = $_SERVER['REQUEST_METHOD']; +$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); +// Normalizar: quitar trailing slash +$path = rtrim($path, '/') ?: '/'; + +// ─── Tabla de rutas ────────────────────────────────────────────────────────── +$routes = [ + ['GET', '/health', fn() => jsonResponse(200, ['status' => 'ok', 'service' => 'bot-palmas360'])], + + // ─── WhatsApp webhook ──────────────────────────────────────────────────── + // Seguridad: HMAC-SHA256 (X-Hub-Signature-256) verificado dentro del controlador + ['GET', '/admin/v1/wp-webhook', fn() => WpWebhook::verify()], + ['POST', '/admin/v1/wp-webhook', fn() => WpWebhook::receive()], + + // ─── Login / Logout ────────────────────────────────────────────────────── + ['GET', '/login', fn() => LoginController::showForm()], + ['POST', '/login', fn() => LoginController::authenticate()], + ['GET', '/logout', fn() => LoginController::logout()], + + // ─── Dashboard protegido ───────────────────────────────────────────────── + ['GET', '/admin/dashboard', fn() => DashboardController::index()], + ['GET', '/admin/live', fn() => DashboardController::live()], + ['GET', '/admin/webhook/stream', fn() => DashboardController::stream()], + ['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()], + + // ─── Páginas legales (requeridas por Meta/WhatsApp Business) ──────────── + ['GET', '/politicas', fn() => serveHtml('politicas.html')], + ['GET', '/eliminacion-datos-usuario', fn() => serveHtml('eliminacion-datos-usuario.html')], + ['GET', '/condiciones-servicio', fn() => serveHtml('condiciones-servicio.html')], +]; + +// ─── Despacho ──────────────────────────────────────────────────────────────── +foreach ($routes as [$routeMethod, $routePath, $handler]) { + if ($method === $routeMethod && $path === $routePath) { + $handler(); + exit; + } +} + +jsonResponse(404, ['error' => 'Ruta no encontrada', 'path' => $path]); diff --git a/public/politicas.html b/public/politicas.html new file mode 100644 index 0000000..f7628ee --- /dev/null +++ b/public/politicas.html @@ -0,0 +1,165 @@ + + + + + + Políticas de Privacidad — Somos19D + + + + +
📋 Documento oficial compartido por WhatsApp · Somos19D
+ +
+
+ NIT: 901677603-5 +

Políticas de Privacidad

+

Somos19D — Empresa jurídica legalmente constituida en Colombia
Vigente a partir del 1 de enero de 2025

+
+
+ +
+

1. ¿Quiénes somos?

+

Somos19D es una empresa jurídicamente constituida en Colombia, identificada con NIT 901677603-5. Operamos servicios digitales y de comunicación, incluyendo atención a través de WhatsApp y canales en línea.

+
+ +
+

2. Datos que recopilamos

+

Para prestar nuestros servicios, podemos recopilar:

+
    +
  • Nombre completo y documento de identidad
  • +
  • Número de teléfono (incluyendo WhatsApp)
  • +
  • Correo electrónico y dirección
  • +
  • Historial de transacciones y servicios solicitados
  • +
  • Información del dispositivo y dirección IP
  • +
+
+ +
+

3. ¿Para qué usamos tus datos?

+
    +
  • Prestar y mejorar nuestros servicios
  • +
  • Comunicarnos contigo por WhatsApp u otros medios
  • +
  • Procesar pagos y gestionar pedidos
  • +
  • Cumplir obligaciones legales y tributarias
  • +
  • Enviarte información relevante sobre nuestros productos
  • +
+ ✅ Nunca vendemos tus datos a terceros +
+ +
+

4. Seguridad de la información

+

Implementamos medidas técnicas y administrativas para proteger tus datos frente a acceso no autorizado, pérdida o divulgación. El acceso a tu información está limitado al personal estrictamente necesario.

+
+ +
+

5. Tus derechos

+

Como titular de tus datos tienes derecho a:

+
    +
  • Conocer, actualizar y corregir tu información
  • +
  • Solicitar prueba de tu autorización de tratamiento
  • +
  • Revocar tu consentimiento en cualquier momento
  • +
  • Solicitar la eliminación de tus datos (ver enlace dedicado)
  • +
  • Presentar quejas ante la Superintendencia de Industria y Comercio (SIC)
  • +
+
+ +
+

6. WhatsApp Business API y Meta

+

Somos19D utiliza la WhatsApp Business Platform operada por Meta Platforms, Inc. para comunicarse contigo. Al interactuar con nosotros por WhatsApp, aceptas que:

+
    +
  • Tu número de teléfono y los mensajes intercambiados son procesados por Meta conforme a sus propias Políticas de Privacidad.
  • +
  • Meta actúa como sub-procesador de datos en la prestación del servicio de mensajería.
  • +
  • Los mensajes enviados a través de WhatsApp están sujetos al cifrado de extremo a extremo de Meta.
  • +
  • Somos19D no accede al contenido de los mensajes fuera de la conversación contigo.
  • +
+ 📱 Consulta la política de Meta: whatsapp.com/legal +
+ +
+

7. Opt-in y Opt-out de mensajes WhatsApp

+

Opt-in (consentimiento): Al escribirnos por WhatsApp o facilitar tu número para recibir comunicaciones, otorgas tu consentimiento expreso para recibir mensajes de Somos19D por este canal.

+

Opt-out (cancelar suscripción): Puedes dejar de recibir mensajes en cualquier momento respondiendo "STOP", "DETENER" o "NO DESEO RECIBIR MENSAJES". Procesaremos tu solicitud en máximo 24 horas.

+ ✅ Nunca recibirás mensajes sin tu consentimiento previo +
+ +
+

8. Compartir información con terceros

+

Somos19D puede compartir datos con proveedores de servicios que actúan en nuestro nombre (pasarelas de pago, plataformas de mensajería como Meta/WhatsApp), bajo estrictos acuerdos de confidencialidad y solo para los fines autorizados.

+
+ +
+

9. Cookies y tecnologías similares

+

Nuestro sitio web puede usar cookies para mejorar la experiencia del usuario. Puedes configurar tu navegador para rechazarlas, aunque algunas funcionalidades pueden verse afectadas.

+
+ +
+

10. Cambios a esta política

+

Podemos actualizar esta política en cualquier momento. Te notificaremos los cambios relevantes a través de nuestros canales oficiales, incluyendo WhatsApp.

+
+ +
+

11. Contacto

+

Para ejercer tus derechos o resolver dudas sobre esta política, comunícate con nosotros a través de los canales oficiales de Somos19D.

+
+ +
+ +
+ + + \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..b5268cc --- /dev/null +++ b/server.js @@ -0,0 +1,30 @@ +require('dotenv').config(); +const express = require('express'); + +const app = express(); + +// Parsear JSON raw para poder verificar firma HMAC de Meta +app.use( + express.json({ + verify: (req, _res, buf) => { + req.rawBody = buf; + }, + }) +); + +// ─── Rutas ─────────────────────────────────────────────────────────────────── +const wpWebhook = require('./admin/v1/wp-webhook'); +app.use('/admin/v1/wp-webhook', wpWebhook); + +// Health check +app.get('/health', (_req, res) => res.json({ status: 'ok', service: 'bot-palmas360' })); + +// 404 +app.use((_req, res) => res.status(404).json({ error: 'Ruta no encontrada' })); + +// ─── Inicio ─────────────────────────────────────────────────────────────────── +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`✅ Servidor corriendo en puerto ${PORT}`); + console.log(`📡 Webhook WhatsApp → /admin/v1/wp-webhook`); +}); diff --git a/setup/migrate.php b/setup/migrate.php new file mode 100644 index 0000000..b842d20 --- /dev/null +++ b/setup/migrate.php @@ -0,0 +1,94 @@ +exec(" + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(150) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +$db->exec(" + CREATE TABLE IF NOT EXISTS webhook_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_field VARCHAR(50) NOT NULL DEFAULT 'messages', + from_number VARCHAR(30), + contact_name VARCHAR(150), + message_type VARCHAR(30), + message_preview TEXT, + raw_payload LONGTEXT, + received_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_received_at (received_at), + INDEX idx_from_number (from_number), + INDEX idx_event_field (event_field) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +$db->exec(" + CREATE TABLE IF NOT EXISTS conversations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + message_id VARCHAR(100) NOT NULL UNIQUE, + phone_number VARCHAR(30) NOT NULL, + contact_name VARCHAR(150), + direction ENUM('inbound','outbound') DEFAULT 'inbound', + message_type VARCHAR(30), + content TEXT, + media_id VARCHAR(150), + timestamp INT UNSIGNED, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_conv_phone (phone_number), + INDEX idx_conv_created (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +$db->exec(" + CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(50) NOT NULL DEFAULT 'new_message', + reference_id BIGINT, + phone_number VARCHAR(30), + message TEXT, + is_read TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_notif_read (is_read), + INDEX idx_notif_created (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +$db->exec(" + CREATE TABLE IF NOT EXISTS media_queue ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + message_id VARCHAR(100) NOT NULL, + media_id VARCHAR(150) NOT NULL, + media_type VARCHAR(30), + attempts TINYINT DEFAULT 0, + processed TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_mq_processed (processed), + INDEX idx_mq_message_id (message_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +// ─── Usuario admin por defecto ──────────────────────────────────────────────── + +$email = 'admin@palmas360.com'; +$password = 'Palmas360@Admin26'; +$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); + +$stmt = $db->prepare("INSERT IGNORE INTO users (name, email, password) VALUES (?, ?, ?)"); +$stmt->execute(['Lizandro', $email, $hash]); + +echo "\n✅ Tablas creadas correctamente.\n"; +echo "👤 Usuario: {$email}\n"; +echo "🔑 Contraseña: {$password}\n"; +echo "⚠️ Cambia la contraseña después del primer login.\n\n";