From c2b493117b5c7ab6994c2953729a79ef4440041e Mon Sep 17 00:00:00 2001 From: Alvaro Romero Date: Fri, 3 Jul 2026 12:15:26 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20AutoBooking=20initial=20commit=20?= =?UTF-8?q?=E2=80=94=20PHP=20WordPress=20Plugin=20(REST=20API,=20wpdb,=20W?= =?UTF-8?q?P=5FUser=5FQuery)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 47 + CHANGES.md | 186 +++ Customer-dashboard-original.html | 4 + PROMPT-claude-code-admin-dashboard.md | 211 +++ SESSION-2026-05-30.md | 130 ++ admin-dashboard.css | 570 +++++++ admin-dashboard.js | 675 ++++++++ autobooking-admin-dashboard.php | 928 +++++++++++ autobooking-command-center.php | 387 +++++ autobooking-driver-dashboard.php | 1340 ++++++++++++++++ autobooking-geo-restrict.php | 242 +++ autobooking-nav-guard.php | 146 ++ autobooking-roles.php | 113 ++ autobooking-security.php | 320 ++++ autobooking-simulator.php | 678 ++++++++ command-center.css | 451 ++++++ command-center.js | 616 ++++++++ .../2026-05-30-security-implementation.md | 583 +++++++ .../specs/2026-05-30-security-design.md | 229 +++ driver-dashboard.css | 1178 ++++++++++++++ driver-dashboard.js | 1404 +++++++++++++++++ 21 files changed, 10438 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGES.md create mode 100644 Customer-dashboard-original.html create mode 100644 PROMPT-claude-code-admin-dashboard.md create mode 100644 SESSION-2026-05-30.md create mode 100644 admin-dashboard.css create mode 100644 admin-dashboard.js create mode 100644 autobooking-admin-dashboard.php create mode 100644 autobooking-command-center.php create mode 100644 autobooking-driver-dashboard.php create mode 100644 autobooking-geo-restrict.php create mode 100644 autobooking-nav-guard.php create mode 100644 autobooking-roles.php create mode 100644 autobooking-security.php create mode 100644 autobooking-simulator.php create mode 100644 command-center.css create mode 100644 command-center.js create mode 100644 docs/superpowers/plans/2026-05-30-security-implementation.md create mode 100644 docs/superpowers/specs/2026-05-30-security-design.md create mode 100644 driver-dashboard.css create mode 100644 driver-dashboard.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab3a7a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# ── WordPress / PHP ──────────────────────────────────────────── +__pycache__/ +*.pyc +.venv/ +env/ + +# Secrets and local config — NEVER commit +.env +.env.* +wp-config.php +wp-config-local.php + +# WordPress core (not part of this plugin repo) +wp-admin/ +wp-includes/ +wp-content/uploads/ +wp-content/cache/ + +# Build / output artefacts +dist/ +build/ +output/ + +# Databases and logs +*.db +*.sqlite +*.log + +# PHP tooling caches +.mypy_cache/ +.phpunit.result.cache +vendor/ + +# Node (if any tooling is added later) +node_modules/ +package-lock.json +yarn.lock + +# Editor and OS files +.DS_Store +Thumbs.db +*.bak +*.swp +*.swo +.idea/ +.vscode/ +*.code-workspace diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..0398317 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,186 @@ +# CHANGES.md — AutoBooking Admin Dashboard + +**Fecha:** 2026-05-29 +**Autor:** Claude Code (claude-sonnet-4-6) +**Tarea:** PARTE A: fix rol corporate_admin + PARTE B: nuevo plugin autobooking-admin-dashboard + +--- + +## ESTADO INICIAL (antes de tocar nada) + +### Plugins existentes +| Plugin | Versión | Archivo principal | +|--------|---------|-------------------| +| autobooking-driver-dashboard | 2.0.0 | autobooking-driver-dashboard.php | +| autobooking-passenger-dashboard | 1.0.0 | autobooking-passenger-dashboard.php | +| autobooking-corporate-dashboard | (sin versión definida) | autobooking-corporate-dashboard.php | +| autobooking-roles | 1.0.0 | autobooking-roles.php | + +### wp_snippets +VACÍO. No hay snippets activos. El plugin Code Snippets está instalado pero sin snippets. + +### wp_options con prefijo fare/tariff/autobooking +NINGUNA. No existe configuración de tarifas en wp_options. + +### Tablas de tarifas dedicadas +NO EXISTEN. Búsqueda de tablas con LIKE '%rate%', '%fare%', '%tarif%', '%price%' devolvió vacío. + +### Sistema de tarifas actual (HALLAZGO CRITICO) +Las tarifas están hardcodeadas en cada plugin con inconsistencias: + +- autobooking-passenger-dashboard.php linea 317: + $base_fare = 2.50 + $per_mile (usa MILLAS) + +- autobooking-corporate-dashboard.php lineas 514-515: + $base = 3.00 + $per_km = 1.80 (usa KILOMETROS) + +Conclusion: No existe un sistema centralizado de tarifas. Los valores son diferentes +entre plugins y usan unidades distintas (millas vs km). Ver TODOs al final. + +### Bug critico identificado +- autobooking-roles v1.0.0 crea roles 'driver' y 'driver_pending' pero NO 'corporate_admin' +- El corporate dashboard verifica in_array('corporate_admin', $user->roles) -> acceso denegado siempre + +### Esquema de tablas verificado +Todas las tablas inspeccionadas. Tabla wp_ab_trips incluye columna country_code char(2). +Tabla wp_ab_company_users: columna user_id bigint(20) NULL (puede o no tener WP user ligado). + +--- + +## CAMBIOS REALIZADOS + +### PARTE A -- autobooking-roles.php + +[MODIFICADO] -- /wp-content/plugins/autobooking-roles/autobooking-roles.php -- v1.0.0 -> v1.1.0 + +Que cambio: +- Agregado rol 'corporate_admin' con caps read + read_corporate_dashboard +- Agregada cap custom 'manage_autobooking' asignada a 'administrator' +- Agregada funcion migracion retroactiva abr_migrate_existing_corporate_users() en activation hook +- Version bumped a 1.1.0 + +CODIGO ORIGINAL COMPLETO (para revertir): + +--- INICIO ORIGINAL --- + true, ABR_CAP_READ_DRIVER_DASH => true ]; + if (!get_role(ABR_ROLE_DRIVER)) { + add_role(ABR_ROLE_DRIVER, 'Driver', $driver_caps); + } else { + $r = get_role(ABR_ROLE_DRIVER); + foreach ($driver_caps as $cap => $grant) { + if (!$r->has_cap($cap)) { $r->add_cap($cap, $grant); } + } + } + + $pending_caps = [ 'read' => true ]; + if (!get_role(ABR_ROLE_DRIVER_PENDING)) { + add_role(ABR_ROLE_DRIVER_PENDING, 'Pending Driver', $pending_caps); + } else { + $r = get_role(ABR_ROLE_DRIVER_PENDING); + foreach ($pending_caps as $cap => $grant) { + if (!$r->has_cap($cap)) { $r->add_cap($cap, $grant); } + } + } + + if ($admin = get_role('administrator')) { + if (!$admin->has_cap(ABR_CAP_READ_DRIVER_DASH)) { $admin->add_cap(ABR_CAP_READ_DRIVER_DASH); } + if (!$admin->has_cap(ABR_CAP_READ_CUST_DASH)) { $admin->add_cap(ABR_CAP_READ_CUST_DASH); } + } +} +register_activation_hook(__FILE__, 'abr_register_roles_caps'); +add_action('init', 'abr_register_roles_caps'); +--- FIN ORIGINAL --- + +### PARTE B -- autobooking-admin-dashboard (nuevo plugin) + +[ANADIDO] -- /wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php -- v1.0.0 +[ANADIDO] -- /wp-content/plugins/autobooking-admin-dashboard/assets/admin-dashboard.css +[ANADIDO] -- /wp-content/plugins/autobooking-admin-dashboard/assets/admin-dashboard.js + +Tablas creadas en activacion (via dbDelta): + +wp_ab_admin_audit: + id BIGINT UNSIGNED AUTO_INCREMENT PK + admin_user_id BIGINT UNSIGNED + action VARCHAR(60) + target_type VARCHAR(40) + target_id BIGINT UNSIGNED + meta LONGTEXT NULL + created_at DATETIME + Keys: admin_user_id, (target_type,target_id), created_at + +wp_ab_fare_config: + id BIGINT UNSIGNED AUTO_INCREMENT PK + country_code CHAR(2) UNIQUE + currency CHAR(3) DEFAULT 'USD' + base_fare DECIMAL(10,2) DEFAULT 3.00 + per_km DECIMAL(10,4) DEFAULT 1.80 + per_minute DECIMAL(10,4) DEFAULT 0.30 + platform_fee_pct DECIMAL(5,4) DEFAULT 0.2000 + minimum_fare DECIMAL(10,2) DEFAULT 5.00 + active TINYINT(1) DEFAULT 1 + updated_at DATETIME + +--- + +## COMO REVERTIR + +### Revertir PARTE A: +1. WP Admin -> Plugins -> AutoBooking Roles -> Desactivar +2. Reemplazar contenido del PHP con el CODIGO ORIGINAL de arriba +3. Reactivar +4. Los roles ya creados en DB no se borran (comportamiento seguro de WordPress) + +### Revertir PARTE B: +1. WP Admin -> Plugins -> AutoBooking Admin Dashboard -> Desactivar +2. Borrar directorio /wp-content/plugins/autobooking-admin-dashboard/ +3. Tablas wp_ab_admin_audit y wp_ab_fare_config quedan en DB (por seguridad) +4. Para borrar tablas manualmente: + DROP TABLE IF EXISTS wp_ab_admin_audit; + DROP TABLE IF EXISTS wp_ab_fare_config; + +--- + +## TODOs / DECISIONES PENDIENTES PARA EL DUENO + +1. [DECISION URGENTE] Inconsistencia de unidades en tarifas: + - passenger-dashboard calcula en MILLAS con base $2.50 + - corporate-dashboard calcula en KILOMETROS con base $3.00 + - El admin dashboard usa km (consistente con la columna fare_distance_m en metros de wp_ab_trips) + - Accion requerida: migrar passenger dashboard a km y unificar valores base + +2. [TODO] Verificacion de documentos de conductores: + - La cola de aprobacion muestra datos basicos pero NO documentos (licencia, seguro, foto vehiculo) + - No existe tabla ni columnas para almacenar URLs de documentos en la DB actual + - Accion: crear wp_ab_driver_documents o usar wp_usermeta + +3. [TODO] Motor de despacho (matching conductor-pasajero): + - Fuera del alcance de este prompt. + +4. [TODO] Stripe Connect / payouts: + - Fuera del alcance. La config de comision existe en el admin pero no ejecuta pagos. + +5. [CANDIDATO A ELIMINAR] Tablas duplicadas de rides: + - wp_ab_rides y wp_autobooking_rides parecen versiones antiguas (no usadas por plugins v2) + - NO se borraron. Decision pendiente del dueno. + +6. [CANDIDATO A ELIMINAR] wp_ab_scheduled_trips: + - Driver dashboard v2 usa wp_ab_trips con status='assigned' para viajes programados + - wp_ab_scheduled_trips parece una version anterior sin uso activo + - NO se borró. Decision pendiente del dueno. diff --git a/Customer-dashboard-original.html b/Customer-dashboard-original.html new file mode 100644 index 0000000..4681d6d --- /dev/null +++ b/Customer-dashboard-original.html @@ -0,0 +1,4 @@ + + + + Sistema de Reserva de Viajes
Agregar paradas intermedias antes del destino. La ruta y estimación se actualizarán automáticamente.
Distancia
Tiempo (tráfico)
Costo estimado
Unidades basadas en tu ubicación. Los costos son estimados; en caso de trancón se hará un reajuste.
diff --git a/PROMPT-claude-code-admin-dashboard.md b/PROMPT-claude-code-admin-dashboard.md new file mode 100644 index 0000000..efca0a4 --- /dev/null +++ b/PROMPT-claude-code-admin-dashboard.md @@ -0,0 +1,211 @@ +# Tarea: Crear plugin "autobooking-admin-dashboard" + arreglar bug crítico de rol + +Eres un desarrollador senior de WordPress. Trabajas dentro de una plataforma tipo Uber +llamada **AutoBooking**, compuesta por varios plugins. Tu trabajo tiene DOS partes: +(A) arreglar un bug crítico de roles, y (B) crear el panel de administrador del dueño. + +**Lee este documento COMPLETO antes de escribir una sola línea de código.** Hay reglas +de auditoría y de reutilización de código existente que son obligatorias y que cambian +la forma en que debes trabajar. + +--- + +## Contexto del sistema existente (NO reinventar) + +Plugins actuales que debes tomar como referencia de estilo y convenciones: + +- **autobooking-driver-dashboard v2.0** (el más completo) +- **autobooking-passenger-dashboard v1.0** +- **autobooking-corporate-dashboard** ← USA ESTE como referencia de UI: + ya tiene íconos SVG profesionales en tabs (sin emojis), idioma ES/EN, y layout limpio. + El admin dashboard debe verse y comportarse consistente con este. +- **autobooking-roles** (tiene el bug a corregir) + +Tablas de base de datos ya existentes (prefijo `wp_`, usar SIEMPRE `$wpdb->prefix`): + +- `wp_ab_trips` — viajes +- `wp_ab_driver_status` — online/offline + telemetría GPS +- `wp_ab_driver_sessions` — sesiones de conductor +- `wp_ab_incidents` — incidentes SOS/pánico, incluye audio +- `wp_ab_zone_alerts` — hotspots/bonus +- `wp_ab_zone_alert_responses` +- `wp_ab_companies` — empresas corporativas +- `wp_ab_company_users` — empleados de empresas +- `wp_ab_invoices` — facturas +- `wp_ab_reviews` — ratings +- `wp_ab_scheduled_trips` — viajes programados +- `wp_ab_trip_positions` — posiciones GPS por viaje (**tabla de alto volumen**) +- `wp_autobooking_chat` — chat pasajero-conductor +- `wp_ab_driver_daily` — agregados diarios por conductor + +**Antes de escribir código:** inspecciona los plugins existentes para deducir el esquema +EXACTO de cada tabla (nombres de columnas, tipos), las convenciones de naming de +funciones/hooks, y cómo registran sus endpoints AJAX. NO asumas columnas; verifícalas. + +--- + +## ⚠️ REGLA #0 — Registro de cambios y verificación (LEER PRIMERO) + +Este sistema ya tiene trabajo previo hecho (snippets, tablas, lógica de tarifas). El mayor +riesgo de esta tarea es **pisar o duplicar algo que ya existe**. Por eso, ANTES de tocar +nada y A MEDIDA que avanzas, mantén un archivo **`CHANGES.md`** en la raíz del trabajo con +TODO lo que toques. No basta con hacer los cambios; se necesita un registro legible por una +persona. Debe incluir: + +- **ESTADO INICIAL:** antes de tocar tarifas/roles/cualquier cosa existente, anota qué + encontraste y dónde (qué snippets, en qué tabla, qué columnas, qué versión de plugin). + Pega los nombres exactos de tablas/columnas/funciones/snippets que vas a tocar. +- **POR CADA CAMBIO,** una línea con: + `[AÑADIDO|MODIFICADO|ELIMINADO|EXTENDIDO] — archivo o tabla afectada — qué cambió — por qué`. +- **ELIMINACIONES:** evita borrar. Si algo te parece obsoleto o duplicado, NO lo borres; + márcalo como "candidato a eliminar" en `CHANGES.md` y explica por qué, para que el + dueño decida. Solo borra si es estrictamente necesario y déjalo registrado. +- **SNIPPETS:** si desactivas o modificas un snippet existente, guarda una COPIA del + código original del snippet dentro de `CHANGES.md` antes de tocarlo, para poder revertir. +- **BASE DE DATOS:** por cada `ALTER`/`CREATE TABLE`, registra la sentencia exacta y si es + reversible. Toda creación/alteración debe ir vía `dbDelta` en el hook de activación. +- **AL FINAL:** una sección **"Cómo revertir"** con los pasos para deshacer cada cambio + mayor, y una lista de TODOs/decisiones pendientes que dejaste para el dueño. + +> Trata `CHANGES.md` como parte del entregable, no como algo opcional. +> Si un cambio no está en `CHANGES.md`, es como si no hubiera pasado. + +--- + +## PARTE A — Bug crítico en autobooking-roles + +**Problema:** el plugin crea los roles `driver` y `driver_pending`, pero NO crea +`corporate_admin`, por lo que las empresas no pueden entrar a su dashboard. + +Requisitos del fix: + +1. Registrar el rol `corporate_admin` con capabilities mínimas necesarias + (`read` + las custom caps que use el corporate-dashboard; dedúcelas del código). +2. Crear también una capability custom `manage_autobooking` para el admin del dueño, + y asignarla al rol `administrator` de WordPress. +3. **MIGRACIÓN RETROACTIVA:** al activar, recorrer `wp_ab_companies` / `wp_ab_company_users` + y asignar el rol `corporate_admin` a los usuarios corporativos que YA existen y + que no lo tengan. Esto debe ser **idempotente** (correrlo dos veces no duplica nada). + *(Las empresas ya creadas no reciben el rol automáticamente solo con cambiar el + activation hook; por eso esta migración es obligatoria.)* +4. En desactivación NO borrar roles ni datos (solo limpieza de caps temporales si aplica). +5. Subir la versión del plugin y dejar un comentario de changelog. + +--- + +## PARTE B — Plugin nuevo: autobooking-admin-dashboard + +Estructura estándar de plugin WP: archivo principal con cabecera, `/includes`, `/assets/css`, +`/assets/js`, carga condicional de scripts solo en la página del dashboard. + +**Acceso:** SOLO usuarios con la capability `manage_autobooking`. Verificar en CADA carga de +página y en CADA endpoint AJAX con `current_user_can('manage_autobooking')`; si no, abortar. + +El dashboard se renderiza vía shortcode `[autobooking_admin]` en una página, con tabs +(íconos SVG, mismo estilo que corporate-dashboard, soporte ES/EN). Tabs: + +### 1. RESUMEN (overview) +- KPIs en (casi) tiempo real: viajes hoy/semana, conductores online ahora, + viajes activos, ingresos del día, incidentes SOS abiertos, conductores pendientes. +- Gráfico de viajes por día (últimos 30 días). + +### 2. CONDUCTORES +- Lista de conductores con búsqueda y filtros (online/offline, rating, estado). +- **Cola de APROBACIÓN:** usuarios con rol `driver_pending`. Para cada uno mostrar sus + datos y documentos. Acciones: **Aprobar** (cambia `driver_pending` → `driver`), + **Rechazar** (con motivo), **Suspender**. Cada acción debe quedar registrada en un + **LOG DE AUDITORÍA** (crear tabla `wp_ab_admin_audit`: `id`, `admin_user_id`, `action`, + `target_type`, `target_id`, `meta` JSON, `created_at`). +- Nota: si el flujo de verificación de documentos (licencia, seguro, tarjeta de + circulación, foto del vehículo) aún no existe, deja la UI preparada con placeholders + claros y un `TODO` marcado; **no inventes columnas**. + +### 3. EMPRESAS (corporativo) +- Lista de `wp_ab_companies`. Acción: **Activar cuenta corporativa**, que debe asignar + el rol `corporate_admin` al/los usuario(s) de esa empresa (reusa la lógica de Parte A). +- Ver facturas (`wp_ab_invoices`) por empresa. + +### 4. VIAJES +- Tabla de TODOS los viajes (`wp_ab_trips`) con filtros por fecha, estado, conductor, + pasajero/empresa. **Paginación server-side.** Exportar CSV. +- Detalle de viaje: ruta en mapa (`wp_ab_trip_positions`), estados, chat, rating. + +### 5. INCIDENTES / SOS +- Lista de `wp_ab_incidents` priorizando los abiertos. Reproducir el audio del SOS. + Mostrar ubicación en mapa, conductor, viaje asociado. Acciones: marcar como + atendido / resuelto, con nota. Registrar en audit log. + +### 6. ZONAS +- Crear/editar/desactivar zone alerts (`wp_ab_zone_alerts`) tipo hotspot/bonus, + dibujando el área en el mapa. Ver respuestas (`wp_ab_zone_alert_responses`). + +### 7. CONFIGURACIÓN +- **IMPORTANTE — TARIFAS POR PAÍS YA EXISTEN:** el sistema ya tiene un mecanismo de + tarifas por país, implementado como **SNIPPETS** (revisa el plugin "Code Snippets" + si está instalado —tabla `wp_snippets`— y también `functions.php` del tema activo y + del child theme) y como una o más **TABLAS** de base de datos. **NO crees un sistema + nuevo.** Tu trabajo es: + 1. **LOCALIZAR** ese sistema: busca en `wp_snippets`, en archivos `functions.php`, + y en `wp_options`/`wp_postmeta` cualquier cosa que contenga + `tarifa`, `fare`, `rate`, `country`, `pais`, `price`, `km`, `base`, `minute`. + 2. **IDENTIFICAR** la(s) tabla(s) reales que guardan tarifa por país (estructura + exacta: columnas, tipos, qué país, base, por km, por minuto, moneda, etc.). + 3. La UI de configuración debe **LEER Y ESCRIBIR sobre esa estructura existente**, + NO sobre una nueva. Si la estructura está incompleta, EXTIÉNDELA con cuidado + (`dbDelta`, columnas nuevas) en vez de reemplazarla. + 4. **Documentar en `CHANGES.md`** qué encontraste y dónde vive cada cosa. +- **Comisión de la plataforma** (% — global o por país si el sistema actual ya lo soporta; + síguelo del mismo modelo que las tarifas por país). +- API key de Google Maps (reutilizar la existente; no duplicar). +- Datos de la plataforma (nombre, logo, email de notificaciones SOS). +- Si encuentras lógica de tarifas DUPLICADA o conflictiva entre snippets y tablas, + **NO borres nada:** documéntalo en `CHANGES.md` y propón cuál debería ser la fuente + única de verdad, dejándolo marcado como decisión pendiente para el dueño. + +--- + +## Requisitos técnicos NO negociables + +- **Seguridad:** TODOS los endpoints AJAX con `check_ajax_referer` (nonce) + + `current_user_can`. TODAS las consultas con `$wpdb->prepare`. TODA salida con + `esc_html`/`esc_attr`/`esc_url`/`wp_kses`. Sanitizar TODA entrada + (`sanitize_text_field`, `absint`, etc.). +- La tabla `wp_ab_trip_positions` es de **alto volumen**: usa `LIMIT` y rangos; nunca + `SELECT *` sin acotar. Si notas falta de índices relevantes para las queries del admin, + créalos en activación (con `dbDelta`). +- Crea la tabla `wp_ab_admin_audit` en activación con `dbDelta`. +- Idioma ES/EN igual que corporate-dashboard (mismo mecanismo, no inventes otro). +- Mapa con Google Maps, reusando el patrón de carga de los plugins existentes. +- Íconos SVG inline en tabs (sin emojis). Estética consistente con corporate-dashboard. +- Código comentado, versión del plugin definida, sin warnings de PHP. + +--- + +## Lo que NO debes hacer (fuera de alcance de este prompt) + +- NO implementar el motor de matching/despacho automático (irá en otro prompt). +- NO implementar el cálculo de tarifas ni Stripe Connect/payouts (otro prompt). + Solo deja la configuración de comisión/tarifa leyendo y escribiendo sobre el sistema + de tarifas por país que YA existe. +- NO tocar la lógica interna de los otros plugins salvo el fix de roles de la Parte A. +- NO borrar snippets, tablas ni columnas. Marca candidatos a eliminar en `CHANGES.md`. + +--- + +## Entregables + +1. Plugin **autobooking-roles** actualizado (Parte A) con changelog. +2. Plugin nuevo **autobooking-admin-dashboard** completo y funcional (Parte B). +3. **`CHANGES.md`** completo (estado inicial, cada cambio, copias de snippets tocados, + sentencias SQL, sección "Cómo revertir", TODOs pendientes). +4. Un breve **`README.md`** con: cómo instalar, qué capability se requiere, qué tablas crea, + y una lista de TODOs/placeholders que dejaste pendientes (verificación de documentos, + integración del motor de despacho, payouts, fuente única de verdad de tarifas si había + conflicto). + +--- + +**Empieza** inspeccionando los plugins, los snippets y el esquema real de las tablas antes +de escribir nada, y abre `CHANGES.md` con el ESTADO INICIAL. Si encuentras una ambigüedad +de esquema que te bloquee, documéntala y elige la opción más segura en vez de adivinar +columnas. diff --git a/SESSION-2026-05-30.md b/SESSION-2026-05-30.md new file mode 100644 index 0000000..a0682c5 --- /dev/null +++ b/SESSION-2026-05-30.md @@ -0,0 +1,130 @@ +# Sesión AutoBooking — 2026-05-29 / 2026-05-30 + +## Qué se hizo en esta sesión + +### PARTE A — Fix: autobooking-roles v1.0 → v1.1.0 +- Agregado rol `corporate_admin` con cap `read_corporate_dashboard` +- Agregada cap `manage_autobooking` al rol `administrator` +- Migración retroactiva idempotente: asigna `corporate_admin` a usuarios con `ab_company_id` en usermeta +- Backup: `autobooking-roles.php.bak` en el servidor + +**Archivo:** `/wp-content/plugins/autobooking-roles/autobooking-roles.php` + +--- + +### PARTE B — Plugin nuevo: autobooking-admin-dashboard v1.0.0 +**Shortcode:** `[autobooking_admin]` +**Acceso:** solo capability `manage_autobooking` + +**7 Tabs (SVG icons, sin emojis, ES/EN):** +1. RESUMEN — KPIs en tiempo real + gráfico 30 días +2. CONDUCTORES — lista + cola de aprobación (approve/reject/suspend) +3. EMPRESAS — activar/desactivar + ver facturas +4. VIAJES — filtros + exportar CSV + detalle con mapa + chat +5. INCIDENTES — SOS activos + audio + resolver +6. ZONAS — crear/editar/desactivar zone alerts en mapa +7. CONFIG — settings plataforma + tarifas por país + +**Tablas creadas:** +- `wp_ab_admin_audit` (id, admin_user_id, action, target_type, target_id, meta JSON, created_at) +- `wp_ab_fare_config` (id, country_code CHAR2 UNIQUE, currency, base_fare, per_km, per_minute, platform_fee_pct, minimum_fare, active, updated_at) + +**Índice agregado:** `wp_ab_trip_positions` → `trip_ts(trip_id, ts)` + +**Archivos:** +``` +/wp-content/plugins/autobooking-admin-dashboard/ + autobooking-admin-dashboard.php + assets/admin-dashboard.css ← glassmorphism = mismo estilo que driver/passenger + assets/admin-dashboard.js +``` + +**Diseño:** mismas variables CSS que el sistema unificado AutoBooking (`--ab-orange #FF6F00`, `--ab-dark #0b0b0b`, `--ab-glass rgba(17,17,17,.55)`, `--ab-glass-border`). Imagen `car-bg.webp` del driver dashboard como fondo al 6% opacidad. + +--- + +### PARTE C — Plugin nuevo: autobooking-geo-restrict v1.1.0 +**Función:** países permitidos configurables desde el admin. Cada usuario solo opera en el país donde está físicamente (bounding boxes GPS, sin API externa). + +**Endpoints REST:** +- `GET /autobooking/v1/geo/check?lat=&lng=` — público, retorna `{allowed, country, message}` +- `GET /autobooking/v1/admin/geo-settings` — lee config (requiere `manage_autobooking`) +- `POST /autobooking/v1/admin/geo-settings/save` — guarda `{allowed_countries:[], blocked_message:""}` + +**Bloquea (HTTP 403):** conductor al ponerse online, pasajero al reservar, corporate al reservar + +**Config en wp_options:** +- `ab_allowed_countries` → `["US"]` por defecto +- `ab_geo_blocked_msg` → mensaje al usuario bloqueado + +**Países con bounding boxes:** US (contiguous+Alaska+Hawaii), CO, MX, CA, GB, ES, AR, BR, CL, PE, EC, VE + +**Archivo:** +``` +/wp-content/plugins/autobooking-geo-restrict/ + autobooking-geo-restrict.php +``` + +--- + +## Estado de plugins en servidor + +| Plugin | Versión | Estado | +|---|---|---| +| autobooking-roles | 1.1.0 | Activo | +| autobooking-driver-dashboard | 2.0.0 | Activo | +| autobooking-passenger-dashboard | 1.0.0 | Activo | +| autobooking-corporate-dashboard | — | Activo | +| autobooking-admin-dashboard | 1.0.0 | Activo | +| **autobooking-geo-restrict** | 1.1.0 | **PENDIENTE ACTIVAR** | + +--- + +## Hallazgos importantes + +**Inconsistencia de tarifas (decisión pendiente del dueño):** +- `passenger-dashboard` calcula en MILLAS con base $2.50 +- `corporate-dashboard` calcula en KILÓMETROS con base $3.00 +- Nueva tabla `wp_ab_fare_config` usa km — acción requerida: migrar ambos plugins para leer de ahí + +**Tablas posiblemente obsoletas (NO borradas, decisión pendiente):** +- `wp_ab_rides` y `wp_autobooking_rides` — versiones antiguas +- `wp_ab_scheduled_trips` — driver v2 usa `wp_ab_trips` con `status='assigned'` + +--- + +## Pendientes próxima sesión + +1. Activar `autobooking-geo-restrict` en WP Admin +2. Agregar panel geo-restrict al tab CONFIG del admin dashboard +3. Unificar tarifas: passenger y corporate leyendo de `wp_ab_fare_config` +4. Crear `wp_ab_driver_documents` para documentos de conductores en cola de aprobación +5. Motor de despacho automático (matching conductor-pasajero) +6. Stripe Connect para pagos y payouts +7. Push notifications para conductores al asignar viaje +8. Sistema de rating del pasajero por conductor + +--- + +## Conexión servidor + +``` +SSH: pi@autobooking.online puerto 2230 +Container WP: autobookingonline-wordpress-autobooking-1 +Container DB: mariadb | DB: AutoBookingDB +``` + +## Archivos locales + +``` +D:\Proyectos Software\AutoBooking\ + CLAUDE.md <- contexto permanente + CHANGES.md <- log de cambios detallado + autobooking-roles.php + autobooking-admin-dashboard.php + admin-dashboard.css + admin-dashboard.js + autobooking-geo-restrict.php + Customer-dashboard-original.html <- diseño original pasajero + SESSION-2026-05-30.md <- este archivo +``` diff --git a/admin-dashboard.css b/admin-dashboard.css new file mode 100644 index 0000000..c05e271 --- /dev/null +++ b/admin-dashboard.css @@ -0,0 +1,570 @@ +/* ================================================================ + AutoBooking Admin Dashboard — CSS v1.2.0 + Mismo lenguaje visual que Driver Dashboard y Passenger Dashboard: + glassmorphism, --ab-orange, --ab-dark, fondo oscuro con imagen. + ================================================================ */ + +/* ── Variables — IDÉNTICAS al sistema unificado AutoBooking ─── */ +:root { + --ab-orange : #FF6F00; + --ab-orange-600 : #e86500; + --ab-dark : #0b0b0b; + --ab-glass : rgba(17,17,17,.55); + --ab-glass-border : rgba(255,255,255,.13); + --ab-text : #fff; + --ab-green : #22C55E; + --ab-red : #ef4444; + --ab-yellow : #f59e0b; + --ab-blue : #3b82f6; + --ab-radius : 14px; + --ab-radius-sm : 8px; + --ab-font : system-ui,-apple-system,'Segoe UI',Arial,sans-serif; + --ab-mono : 'SF Mono','Fira Code',monospace; +} + +/* ── Reset ─────────────────────────────────────────────────── */ +#abad-app *, +#abad-app *::before, +#abad-app *::after { box-sizing:border-box; margin:0; padding:0; } + +/* ── Raíz — fondo oscuro con imagen de vehículo ────────────── */ +#abad-app { + background : var(--ab-dark); + color : var(--ab-text); + font-family : var(--ab-font); + font-size : 14px; + line-height : 1.5; + min-height : 100vh; + position : relative; + -webkit-font-smoothing: antialiased; +} + +/* Overlay de fondo — imagen de vehículo sutil */ +#abad-app::before { + content : ''; + position : fixed; + inset : 0; + background-image : url('/wp-content/plugins/autobooking-driver-dashboard/assets/car-bg.webp'); + background-size : cover; + background-position : center 30%; + background-repeat : no-repeat; + opacity : 0.06; + z-index : 0; + pointer-events: none; +} + +#abad-app > * { position: relative; z-index: 1; } + +/* ── Header ─────────────────────────────────────────────────── */ +.abad-header { + display : flex; + align-items : center; + justify-content : space-between; + padding : 0 22px; + height : 58px; + background : rgba(10,10,10,.92); + border-bottom : 1px solid var(--ab-glass-border); + backdrop-filter : blur(12px); + position : sticky; + top : 0; + z-index : 200; + gap : 16px; +} + +.abad-header__brand { + display : flex; + align-items : center; + gap : 12px; + flex-shrink : 0; +} + +.abad-header__logo { height:30px; border-radius:6px; } + +.abad-header__icon { + width : 36px; + height : 36px; + background : rgba(255,111,0,.15); + border : 1px solid rgba(255,111,0,.30); + border-radius : 10px; + display : grid; + place-items : center; + flex-shrink : 0; +} + +.abad-header__wordmark { display:flex; flex-direction:column; gap:2px; } +.abad-header__name { font-size:15px; font-weight:800; color:var(--ab-text); letter-spacing:.01em; line-height:1; } +.abad-header__role { font-size:9.5px; color:var(--ab-orange); text-transform:uppercase; letter-spacing:.10em; font-weight:700; line-height:1; } + +.abad-header__right { display:flex; align-items:center; gap:12px; } + +.abad-header__user { + font-size : 12px; + color : rgba(255,255,255,.55); + background : rgba(255,255,255,.06); + border : 1px solid var(--ab-glass-border); + border-radius: 20px; + padding : 4px 12px; +} + +.abad-sos-indicator { + display : flex; + align-items : center; + gap : 6px; + background : rgba(239,68,68,.15); + border : 1px solid rgba(239,68,68,.35); + border-radius : 20px; + padding : 5px 13px; + font-size : 11.5px; + font-weight : 800; + color : var(--ab-red); + cursor : pointer; + animation : ab-alert 1.6s ease-in-out infinite; +} +@keyframes ab-alert { + 0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } + 50% { box-shadow: 0 0 0 5px rgba(239,68,68,.15); } +} + +.abad-lang-btn { + background : rgba(255,255,255,.07); + border : 1px solid var(--ab-glass-border); + color : rgba(255,255,255,.55); + font-size : 11px; + font-weight : 800; + letter-spacing:.06em; + border-radius: var(--ab-radius-sm); + padding : 5px 11px; + cursor : pointer; + transition : all .15s; +} +.abad-lang-btn:hover { border-color:var(--ab-orange); color:var(--ab-orange); } + +/* ── Tab Navigation ─────────────────────────────────────────── */ +.abad-tabs { + display : flex; + background : rgba(8,8,8,.85); + border-bottom : 1px solid var(--ab-glass-border); + backdrop-filter : blur(10px); + overflow-x : auto; + scrollbar-width : none; + padding : 0 18px; + gap : 2px; +} +.abad-tabs::-webkit-scrollbar { display:none; } + +.abad-tab { + display : flex; + align-items : center; + gap : 7px; + padding : 15px 16px; + background : none; + border : none; + border-bottom : 2px solid transparent; + color : rgba(255,255,255,.35); + font-size : 11px; + font-weight : 700; + letter-spacing : .07em; + cursor : pointer; + white-space : nowrap; + transition : color .15s, border-color .15s; + text-transform : uppercase; + font-family : var(--ab-font); +} +.abad-tab svg { opacity:.5; transition:opacity .15s; } +.abad-tab:hover { color:rgba(255,255,255,.7); } +.abad-tab:hover svg{ opacity:.8; } +.abad-tab--active { color:var(--ab-orange); border-bottom-color:var(--ab-orange); } +.abad-tab--active svg { opacity:1; } + +.abad-badge { + display : inline-flex; + align-items : center; + justify-content: center; + min-width : 18px; + height : 18px; + padding : 0 5px; + background : var(--ab-orange); + color : #fff; + font-size : 10px; + font-weight : 800; + border-radius : 9px; + line-height : 1; +} +.abad-badge--red { background: var(--ab-red); } +.abad-badge--inline { margin-left:2px; } + +/* ── Panels ─────────────────────────────────────────────────── */ +.abad-panel { padding:22px; max-width:1360px; margin:0 auto; } + +/* ── KPI Grid ───────────────────────────────────────────────── */ +.abad-kpi-grid { + display : grid; + grid-template-columns : repeat(auto-fill,minmax(175px,1fr)); + gap : 14px; + margin-bottom : 20px; +} + +.abad-kpi { + background : var(--ab-glass); + border : 1px solid var(--ab-glass-border); + border-radius : var(--ab-radius); + padding : 18px 20px 16px; + backdrop-filter : blur(12px); + position : relative; + overflow : hidden; + transition : transform .18s, border-color .18s; + cursor : default; +} +.abad-kpi::before { + content : ''; + position : absolute; + top:0; left:0; right:0; + height : 2px; + background: var(--ab-glass-border); +} +.abad-kpi:hover { transform:translateY(-2px); border-color:rgba(255,255,255,.22); } + +/* Colored accent per type */ +.abad-kpi--or::before { background: var(--ab-orange); } +.abad-kpi--gr::before { background: var(--ab-green); } +.abad-kpi--re::before { background: var(--ab-red); } +.abad-kpi--ye::before { background: var(--ab-yellow); } +.abad-kpi--bl::before { background: var(--ab-blue); } +.abad-kpi--pu::before { background: #a78bfa; } + +.abad-kpi--re { border-color:rgba(239,68,68,.25); } +.abad-kpi--ye { border-color:rgba(245,158,11,.22); } + +.abad-kpi__icon { + width : 36px; + height : 36px; + border-radius : 10px; + display : grid; + place-items : center; + margin-bottom : 14px; + background : rgba(255,255,255,.07); + border : 1px solid var(--ab-glass-border); +} +.abad-kpi--or .abad-kpi__icon { background:rgba(255,111,0,.15); color:var(--ab-orange); border-color:rgba(255,111,0,.25); } +.abad-kpi--gr .abad-kpi__icon { background:rgba(34,197,94,.12); color:var(--ab-green); border-color:rgba(34,197,94,.22); } +.abad-kpi--re .abad-kpi__icon { background:rgba(239,68,68,.12); color:var(--ab-red); border-color:rgba(239,68,68,.22); } +.abad-kpi--ye .abad-kpi__icon { background:rgba(245,158,11,.12);color:var(--ab-yellow); border-color:rgba(245,158,11,.22); } +.abad-kpi--bl .abad-kpi__icon { background:rgba(59,130,246,.12);color:var(--ab-blue); border-color:rgba(59,130,246,.22); } +.abad-kpi--pu .abad-kpi__icon { background:rgba(167,139,250,.12);color:#a78bfa; border-color:rgba(167,139,250,.22); } + +.abad-kpi__val { + font-size : 2.5rem; + font-weight : 800; + color : var(--ab-text); + line-height : 1; + margin-bottom : 6px; + font-feature-settings: "tnum"; + letter-spacing : -.02em; +} +.abad-kpi--or .abad-kpi__val { color:var(--ab-orange); } +.abad-kpi--gr .abad-kpi__val { color:var(--ab-green); } +.abad-kpi--re .abad-kpi__val { color:var(--ab-red); } +.abad-kpi--ye .abad-kpi__val { color:var(--ab-yellow); } +.abad-kpi--bl .abad-kpi__val { color:var(--ab-blue); } + +.abad-kpi__label { + font-size : 10px; + font-weight : 700; + text-transform : uppercase; + letter-spacing : .08em; + color : rgba(255,255,255,.45); +} + +/* ── Glass Card ─────────────────────────────────────────────── */ +.abad-card { + background : var(--ab-glass); + border : 1px solid var(--ab-glass-border); + border-radius : var(--ab-radius); + backdrop-filter : blur(10px); + overflow : hidden; +} +.abad-card__header { + display : flex; + align-items : center; + justify-content : space-between; + padding : 13px 18px; + border-bottom : 1px solid var(--ab-glass-border); +} +.abad-card__title { + font-size : 10.5px; + font-weight : 700; + text-transform : uppercase; + letter-spacing : .08em; + color : rgba(255,255,255,.5); +} +.abad-card__body { padding:16px 18px; } + +/* Bar chart */ +.abad-bar-chart { display:flex; align-items:flex-end; gap:3px; height:72px; } +.abad-bar-chart__bar { + flex : 1; + background : linear-gradient(to top, var(--ab-orange), rgba(255,111,0,.4)); + border-radius : 3px 3px 0 0; + min-width : 3px; + opacity : 0.6; + transition : opacity .15s; + cursor : default; +} +.abad-bar-chart__bar:hover { opacity:1; } + +.abad-section-title { + font-size : 14px; + font-weight : 800; + color : var(--ab-text); + margin-bottom: 14px; + padding-top : 6px; +} + +/* ── Toolbar ─────────────────────────────────────────────────── */ +.abad-toolbar { display:flex; align-items:center; gap:8px; margin-bottom:16px; flex-wrap:wrap; } +.abad-spacer { flex:1; } + +/* ── Table ───────────────────────────────────────────────────── */ +.abad-table-wrap { + background : var(--ab-glass); + border : 1px solid var(--ab-glass-border); + border-radius : var(--ab-radius); + backdrop-filter : blur(8px); + overflow : auto; + margin-bottom : 12px; +} +.abad-table { width:100%; border-collapse:collapse; font-size:13.5px; } +.abad-table thead { background:rgba(0,0,0,.3); } +.abad-table th { + padding : 11px 16px; + text-align : left; + font-size : 10px; + font-weight : 700; + text-transform : uppercase; + letter-spacing : .08em; + color : rgba(255,255,255,.4); + border-bottom : 1px solid var(--ab-glass-border); + white-space : nowrap; +} +.abad-table td { + padding : 13px 16px; + border-bottom : 1px solid rgba(255,255,255,.05); + vertical-align: middle; + color : var(--ab-text); +} +.abad-table tr:last-child td { border-bottom:none; } +.abad-table tbody tr { transition:background .1s; } +.abad-table tbody tr:hover td { background:rgba(255,255,255,.04); } + +/* Avatar in table */ +.abad-av { display:inline-flex; align-items:center; gap:10px; } +.abad-av__img { width:34px;height:34px;border-radius:50%;object-fit:cover;border:2px solid var(--ab-glass-border);flex-shrink:0; } +.abad-av__init { width:34px;height:34px;border-radius:50%;background:rgba(255,255,255,.08);border:1px solid var(--ab-glass-border);display:grid;place-items:center;font-size:12px;font-weight:700;color:rgba(255,255,255,.6);flex-shrink:0; } +.abad-av__name { font-weight:700;font-size:13.5px;display:block;line-height:1.3; } +.abad-av__sub { font-size:11px;color:rgba(255,255,255,.45);display:block; } + +/* Pill badges */ +.abad-pill { display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;white-space:nowrap; } +.abad-pill__dot { width:5px;height:5px;border-radius:50%;flex-shrink:0; } +.abad-pill--on { background:rgba(34,197,94,.14); color:var(--ab-green); border:1px solid rgba(34,197,94,.25); } +.abad-pill--on .abad-pill__dot { background:var(--ab-green); box-shadow:0 0 6px var(--ab-green); } +.abad-pill--off { background:rgba(255,255,255,.06); color:rgba(255,255,255,.4); border:1px solid var(--ab-glass-border); } +.abad-pill--off .abad-pill__dot { background:rgba(255,255,255,.3); } +.abad-pill--re { background:rgba(239,68,68,.14); color:var(--ab-red); border:1px solid rgba(239,68,68,.28); } +.abad-pill--ye { background:rgba(245,158,11,.12);color:var(--ab-yellow); border:1px solid rgba(245,158,11,.25); } +.abad-pill--gr { background:rgba(34,197,94,.12); color:var(--ab-green); border:1px solid rgba(34,197,94,.22); } + +/* Status chips */ +.abad-chip { display:inline-block;padding:3px 9px;border-radius:6px;font-size:11px;font-weight:700;font-family:var(--ab-mono);background:rgba(255,255,255,.08);color:rgba(255,255,255,.6);border:1px solid var(--ab-glass-border); } +.abad-chip--fin { background:rgba(34,197,94,.12); color:var(--ab-green); border-color:rgba(34,197,94,.25); } +.abad-chip--act { background:rgba(255,111,0,.14); color:var(--ab-orange); border-color:rgba(255,111,0,.30); } +.abad-chip--asgn { background:rgba(59,130,246,.12);color:var(--ab-blue); border-color:rgba(59,130,246,.25); } +.abad-chip--can { background:rgba(239,68,68,.12); color:var(--ab-red); border-color:rgba(239,68,68,.28); } + +/* ── Item Cards ──────────────────────────────────────────────── */ +.abad-cards-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:14px; } + +.abad-item-card { + background : var(--ab-glass); + border : 1px solid var(--ab-glass-border); + border-radius : var(--ab-radius); + padding : 18px; + backdrop-filter : blur(8px); + transition : border-color .15s, transform .15s; +} +.abad-item-card:hover { border-color:rgba(255,255,255,.22); transform:translateY(-1px); } +.abad-item-card--re { border-color:rgba(239,68,68,.30); } +.abad-item-card--ye { border-color:rgba(245,158,11,.25); } + +.abad-icard-head { display:flex;align-items:flex-start;gap:12px;margin-bottom:12px; } +.abad-icard-av { width:44px;height:44px;border-radius:12px;background:rgba(255,255,255,.08);border:1px solid var(--ab-glass-border);display:grid;place-items:center;font-size:15px;font-weight:800;color:rgba(255,255,255,.6);flex-shrink:0;overflow:hidden; } +.abad-icard-av img { width:100%;height:100%;object-fit:cover; } +.abad-icard-name { font-weight:800;font-size:14px;color:var(--ab-text);line-height:1.3;margin-bottom:2px;display:block; } +.abad-icard-sub { font-size:11px;color:rgba(255,255,255,.45);display:block; } +.abad-icard-meta { font-size:12px;color:rgba(255,255,255,.6);display:flex;flex-direction:column;gap:5px;margin-bottom:14px; } +.abad-icard-meta-r { display:flex;align-items:center;gap:6px; } +.abad-icard-meta-r svg { color:rgba(255,255,255,.35);flex-shrink:0; } +.abad-icard-actions { display:flex;gap:8px;flex-wrap:wrap;padding-top:13px;border-top:1px solid var(--ab-glass-border); } + +/* ── Pagination ──────────────────────────────────────────────── */ +.abad-pagination { display:flex;gap:4px;margin-top:10px;flex-wrap:wrap;align-items:center; } +.abad-page-btn { + padding : 6px 12px; + background : var(--ab-glass); + border : 1px solid var(--ab-glass-border); + border-radius: var(--ab-radius-sm); + color : rgba(255,255,255,.5); + font-size : 12px; + font-weight : 600; + cursor : pointer; + transition : all .1s; + font-family : var(--ab-mono); + backdrop-filter: blur(6px); +} +.abad-page-btn--active { background:rgba(255,111,0,.18);border-color:rgba(255,111,0,.45);color:var(--ab-orange);font-weight:800; } +.abad-page-btn:hover:not(:disabled) { border-color:rgba(255,255,255,.25);color:var(--ab-text); } +.abad-page-btn:disabled { opacity:.35;cursor:default; } + +/* ── Map ─────────────────────────────────────────────────────── */ +.abad-map { width:100%;height:300px;border-radius:var(--ab-radius);overflow:hidden;background:rgba(0,0,0,.4);border:1px solid var(--ab-glass-border);margin-bottom:16px; } +.abad-map--modal { height:230px;border-radius:var(--ab-radius) var(--ab-radius) 0 0;margin-bottom:0;border:none;border-bottom:1px solid var(--ab-glass-border); } + +/* ── Buttons ─────────────────────────────────────────────────── */ +.abad-btn { + display : inline-flex; + align-items : center; + gap : 7px; + padding : 9px 18px; + border-radius : var(--ab-radius-sm); + font-size : 13px; + font-weight : 800; + font-family : var(--ab-font); + cursor : pointer; + border : 2px solid transparent; + transition : all .15s; + white-space : nowrap; + line-height : 1; + letter-spacing : .2px; + text-transform : uppercase; +} +.abad-btn:disabled { opacity:.5;cursor:default;pointer-events:none; } +.abad-btn:active { transform:translateY(1px); } + +.abad-btn--brand { background:var(--ab-orange);color:#fff;box-shadow:0 2px 12px rgba(255,111,0,.35); } +.abad-btn--brand:hover { background:var(--ab-orange-600); } + +.abad-btn--outline { background:transparent;border-color:var(--ab-glass-border);color:rgba(255,255,255,.7); } +.abad-btn--outline:hover { border-color:rgba(255,255,255,.3);color:var(--ab-text);background:rgba(255,255,255,.05); } + +.abad-btn--ghost { background:rgba(255,255,255,.07);color:rgba(255,255,255,.7);border-color:var(--ab-glass-border); } +.abad-btn--ghost:hover { background:rgba(255,255,255,.12);color:var(--ab-text); } + +.abad-btn--danger { background:rgba(239,68,68,.15);color:var(--ab-red);border-color:rgba(239,68,68,.30); } +.abad-btn--danger:hover { background:rgba(239,68,68,.25); } + +.abad-btn--approve { background:rgba(34,197,94,.14);color:var(--ab-green);border-color:rgba(34,197,94,.28); } +.abad-btn--approve:hover { background:rgba(34,197,94,.24); } + +.abad-btn--sm { padding:6px 13px;font-size:11.5px; } +.abad-btn--wide { width:100%;justify-content:center;padding:11px 18px; } + +/* ── Forms ───────────────────────────────────────────────────── */ +.abad-input,.abad-select,.abad-textarea { + width : 100%; + background : rgba(255,255,255,.07); + border : 1px solid var(--ab-glass-border); + border-radius : var(--ab-radius-sm); + color : var(--ab-text); + padding : 9px 13px; + font-size : 13px; + font-family : var(--ab-font); + outline : none; + transition : border-color .15s, box-shadow .15s; + backdrop-filter : blur(4px); +} +.abad-input:focus,.abad-select:focus,.abad-textarea:focus { + border-color: rgba(255,111,0,.6); + box-shadow : 0 0 0 3px rgba(255,111,0,.10); +} +.abad-input::placeholder,.abad-textarea::placeholder { color:rgba(255,255,255,.25); } +.abad-input--sm,.abad-select--sm { padding:7px 10px;font-size:12px; } +.abad-select { appearance:none;cursor:pointer; } +.abad-textarea { resize:vertical;line-height:1.55; } +.abad-form-group { margin-bottom:14px; } +.abad-label { display:block;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,.4);margin-bottom:6px; } +.abad-form-row { display:flex;gap:10px;flex-wrap:wrap; } +.abad-form-row .abad-form-group { flex:1;min-width:100px; } + +/* ── Alerts ──────────────────────────────────────────────────── */ +.abad-alert { padding:10px 14px;border-radius:var(--ab-radius-sm);font-size:12.5px;margin-bottom:12px;backdrop-filter:blur(4px); } +.abad-alert--success { background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.25);color:var(--ab-green); } +.abad-alert--info { background:rgba(59,130,246,.10);border:1px solid rgba(59,130,246,.22);color:var(--ab-blue); } +.abad-note { font-size:12px;color:rgba(255,255,255,.4);margin-bottom:14px;line-height:1.6;padding:10px 13px;background:rgba(255,255,255,.05);border-radius:var(--ab-radius-sm);border-left:3px solid var(--ab-glass-border); } + +/* Toast */ +.abad-toast { position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none; } +.abad-toast__item { padding:13px 18px;border-radius:var(--ab-radius);font-size:13px;font-weight:600;background:rgba(17,17,17,.95);border:1px solid var(--ab-glass-border);color:var(--ab-text);box-shadow:0 8px 32px rgba(0,0,0,.8);pointer-events:auto;animation:ab-toastin .25s ease-out;max-width:320px;backdrop-filter:blur(12px); } +.abad-toast__item--ok { border-color:rgba(34,197,94,.35); } +.abad-toast__item--err { border-color:rgba(239,68,68,.35); } +@keyframes ab-toastin { from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)} } + +.abad-empty-state { text-align:center;color:rgba(255,255,255,.35);padding:40px 0;font-size:13px; } + +/* ── Settings ────────────────────────────────────────────────── */ +.abad-settings-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(370px,1fr));gap:20px; } +.abad-settings-card { background:var(--ab-glass);border:1px solid var(--ab-glass-border);border-radius:var(--ab-radius);overflow:hidden;backdrop-filter:blur(10px); } +.abad-settings-card__head { padding:14px 18px;border-bottom:1px solid var(--ab-glass-border);background:rgba(0,0,0,.2);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,.45); } +.abad-settings-card__body { padding:20px; } +.abad-fare-table { width:100%;border-collapse:collapse;font-size:12px;margin-top:12px; } +.abad-fare-table th { padding:8px 10px;text-align:left;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,.35);border-bottom:1px solid var(--ab-glass-border); } +.abad-fare-table td { padding:10px 10px;border-bottom:1px solid rgba(255,255,255,.05);color:rgba(255,255,255,.6);font-family:var(--ab-mono); } +.abad-fare-table tr:last-child td { border-bottom:none; } +.abad-fare-table td:first-child { font-weight:800;color:var(--ab-text);font-family:var(--ab-font); } + +/* ── Modals ──────────────────────────────────────────────────── */ +.abad-overlay { + position : fixed; + inset : 0; + background : rgba(0,0,0,.75); + backdrop-filter : blur(6px); + display : flex; + align-items : center; + justify-content : center; + z-index : 1000; + padding : 20px; +} +.abad-modal { + background : rgba(14,14,14,.97); + border : 1px solid var(--ab-glass-border); + border-radius : 18px; + max-width : 520px; + width : 100%; + max-height : 90vh; + overflow-y : auto; + position : relative; + box-shadow : 0 24px 80px rgba(0,0,0,.9); + backdrop-filter : blur(20px); +} +.abad-modal--wide { max-width:780px; } +.abad-modal__header { display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--ab-glass-border); } +.abad-modal__title { font-size:16px;font-weight:800;color:var(--ab-text); } +.abad-modal__close { width:32px;height:32px;background:rgba(255,255,255,.07);border:1px solid var(--ab-glass-border);border-radius:8px;display:grid;place-items:center;cursor:pointer;color:rgba(255,255,255,.5);transition:all .15s; } +.abad-modal__close:hover { color:var(--ab-text);border-color:rgba(255,255,255,.25); } +.abad-modal__body { padding:22px; } +.abad-modal__footer { display:flex;gap:10px;padding:16px 22px;border-top:1px solid var(--ab-glass-border);background:rgba(0,0,0,.2); } + +/* ── Scrollbar ───────────────────────────────────────────────── */ +#abad-app ::-webkit-scrollbar { width:5px;height:5px; } +#abad-app ::-webkit-scrollbar-track { background:transparent; } +#abad-app ::-webkit-scrollbar-thumb { background:rgba(255,255,255,.14);border-radius:3px; } + +/* ── Responsive ──────────────────────────────────────────────── */ +@media(max-width:768px){ + .abad-kpi-grid { grid-template-columns:repeat(2,1fr); } + .abad-kpi__val { font-size:1.9rem; } + .abad-panel { padding:14px; } + .abad-header { padding:0 14px; } + .abad-header__user { display:none; } + .abad-settings-grid { grid-template-columns:1fr; } + .abad-cards-grid { grid-template-columns:1fr; } +} diff --git a/admin-dashboard.js b/admin-dashboard.js new file mode 100644 index 0000000..d7ae5cf --- /dev/null +++ b/admin-dashboard.js @@ -0,0 +1,675 @@ +/* AutoBooking Admin Dashboard — admin-dashboard.js v1.0.0 */ +(function () { + 'use strict'; + + const CFG = window.AB_ADMIN_CFG || {}; + const API = CFG.api_root + 'autobooking/v1/admin/'; + const NONCE = CFG.nonce; + + let lang = 'es'; + let currentTab = 'overview'; + let driversPage = 1, companiesPage = 1, tripsPage = 1, incidentsPage = 1; + let reasonCallback = null; + let currentIncidentId = null; + let zoneMap = null, zoneMarker = null, zoneCircle = null; + let tripMap = null, incidentMap = null, zonesOverviewMap = null; + + /* ── API ── */ + function apiFetch(path, opts) { + opts = opts || {}; + return fetch(API + path, Object.assign({ headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json' } }, opts)).then(function(r){ return r.json(); }); + } + + /* ── DOM ── */ + function qs(sel, ctx) { return (ctx || document).querySelector(sel); } + function setText(id, val) { var el = qs('#' + id); if (el) el.textContent = val; } + function show(id) { var el = qs('#' + id); if (el) el.style.display = ''; } + function hide(id) { var el = qs('#' + id); if (el) el.style.display = 'none'; } + + function applyLang() { + document.querySelectorAll('[data-es]').forEach(function(el) { + el.textContent = lang === 'en' ? (el.dataset.en || el.dataset.es) : el.dataset.es; + }); + var btn = qs('#abad-lang-btn'); + if (btn) btn.textContent = lang === 'en' ? 'EN' : 'ES'; + } + + function fmtMoney(v, currency) { return (currency || 'USD') + ' ' + parseFloat(v || 0).toFixed(2); } + function fmtDate(dt) { + if (!dt) return '—'; + return new Date(dt).toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' }); + } + + function escHtml(str) { + if (str == null) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + function statusDot(online) { + return ''; + } + + function renderPagination(containerId, currentPage, total, perPage, onClick) { + var container = qs('#' + containerId); + if (!container) return; + var totalPages = Math.ceil(total / perPage) || 1; + container.innerHTML = ''; + if (totalPages <= 1) return; + + function mkBtn(label, page, active) { + var btn = document.createElement('button'); + btn.className = 'abad-page-btn' + (active ? ' abad-page-btn--active' : ''); + btn.textContent = label; + btn.disabled = (page < 1 || page > totalPages); + btn.onclick = function() { onClick(page); }; + container.appendChild(btn); + } + + mkBtn(lang === 'en' ? 'Prev' : 'Ant', currentPage - 1, false); + for (var i = 1; i <= totalPages; i++) { + if (totalPages > 7 && Math.abs(i - currentPage) > 2 && i !== 1 && i !== totalPages) continue; + mkBtn(i, i, i === currentPage); + } + mkBtn(lang === 'en' ? 'Next' : 'Sig', currentPage + 1, false); + } + + function darkMapStyle() { + return [ + { elementType: 'geometry', stylers: [{ color: '#212121' }] }, + { elementType: 'labels.text.fill', stylers: [{ color: '#757575' }] }, + { featureType: 'road', elementType: 'geometry', stylers: [{ color: '#2c2c2c' }] }, + { featureType: 'water', elementType: 'geometry', stylers: [{ color: '#000000' }] }, + ]; + } + + function downloadCSV(csv, filename) { + var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; a.download = filename || 'export.csv'; + document.body.appendChild(a); a.click(); + document.body.removeChild(a); URL.revokeObjectURL(url); + } + + /* ── Tabs ── */ + function switchTab(tab) { + currentTab = tab; + document.querySelectorAll('.abad-tab').forEach(function(b) { + b.classList.toggle('abad-tab--active', b.dataset.tab === tab); + }); + document.querySelectorAll('.abad-panel').forEach(function(p) { p.style.display = 'none'; }); + var panel = qs('#panel-' + tab); + if (panel) panel.style.display = ''; + loadTab(tab); + } + + function loadTab(tab) { + if (tab === 'overview') loadOverview(); + if (tab === 'drivers') { loadDrivers(); loadPending(); } + if (tab === 'companies') loadCompanies(); + if (tab === 'trips') loadTrips(); + if (tab === 'incidents') loadIncidents(); + if (tab === 'zones') loadZones(); + if (tab === 'settings') loadSettings(); + } + + /* ── Overview ── */ + function loadOverview() { + apiFetch('overview').then(function(data) { + setText('kpi-trips-today', data.trips_today != null ? data.trips_today : '—'); + setText('kpi-trips-week', data.trips_week != null ? data.trips_week : '—'); + setText('kpi-active', data.active_trips != null ? data.active_trips : '—'); + setText('kpi-online', data.drivers_online != null ? data.drivers_online : '—'); + setText('kpi-revenue', '$' + parseFloat(data.revenue_today || 0).toFixed(2)); + setText('kpi-sos', data.sos_open != null ? data.sos_open : 0); + setText('kpi-pending', data.pending_drivers != null ? data.pending_drivers : 0); + + var sosWrap = qs('#kpi-sos-wrap'); + var pendWrap = qs('#kpi-pending-wrap'); + if (sosWrap) sosWrap.style.display = data.sos_open > 0 ? '' : 'none'; + if (pendWrap) pendWrap.style.display = data.pending_drivers > 0 ? '' : 'none'; + + var sosInd = qs('#abad-sos-indicator'); + if (sosInd) { sosInd.style.display = data.sos_open > 0 ? 'flex' : 'none'; setText('abad-sos-count', data.sos_open); } + + var sosBadge = qs('#tab-sos-badge'); + var pendBadge = qs('#tab-pending-badge'); + if (sosBadge) { sosBadge.style.display = data.sos_open > 0 ? '' : 'none'; sosBadge.textContent = data.sos_open; } + if (pendBadge) { pendBadge.style.display = data.pending_drivers > 0 ? '' : 'none'; pendBadge.textContent = data.pending_drivers; } + + var inline = qs('#pending-count-inline'); + if (inline) inline.textContent = data.pending_drivers > 0 ? data.pending_drivers : ''; + + renderChart(data.chart || []); + }); + } + + function renderChart(chart) { + var container = qs('#abad-overview-chart'); + if (!container) return; + container.innerHTML = ''; + if (!chart.length) { container.innerHTML = '

Sin datos.

'; return; } + var max = Math.max.apply(null, chart.map(function(d){ return parseInt(d.total) || 0; })) || 1; + chart.forEach(function(day) { + var bar = document.createElement('div'); + bar.className = 'abad-bar-chart__bar'; + var pct = Math.max(4, Math.round((parseInt(day.total || 0) / max) * 100)); + bar.style.height = pct + '%'; + bar.title = day.the_date + ': ' + day.total + ' viajes | $' + parseFloat(day.revenue || 0).toFixed(2); + container.appendChild(bar); + }); + } + + /* ── Drivers ── */ + function loadDrivers(page) { + if (page) driversPage = page; + var search = (qs('#driver-search') || {}).value || ''; + var filter = (qs('#driver-filter') || {}).value || ''; + apiFetch('drivers?page=' + driversPage + '&search=' + encodeURIComponent(search) + '&filter=' + filter).then(function(data) { + var tbody = qs('#drivers-tbody'); + if (!tbody) return; + if (!data.drivers || !data.drivers.length) { + tbody.innerHTML = 'Sin conductores'; + return; + } + tbody.innerHTML = data.drivers.map(function(d) { + return '' + + '' + (d.photo_url ? '' : '') + + escHtml(d.name) + '
' + escHtml(d.email) + '' + + '' + escHtml(d.vehicle_type || '—') + ' ' + escHtml(d.vehicle_plate || '') + '' + + '' + statusDot(d.online) + (d.online ? 'Online' : 'Offline') + '' + + '' + (d.avg_rating > 0 ? '★ ' + d.avg_rating : '—') + '' + + '' + d.trips_total + '' + + '' + + ''; + }).join(''); + applyLang(); + renderPagination('drivers-pagination', driversPage, data.total, data.per_page, loadDrivers); + }); + } + + function loadPending() { + apiFetch('drivers/pending').then(function(data) { + var list = qs('#abad-pending-list'); + var count = (data.drivers || []).length; + + var badge = qs('#tab-pending-badge'); + var inline = qs('#pending-count-inline'); + if (badge) { badge.style.display = count > 0 ? '' : 'none'; badge.textContent = count; } + if (inline) { inline.textContent = count > 0 ? count : ''; } + + if (!list) return; + if (!count) { list.innerHTML = '

No hay conductores pendientes.

'; return; } + + list.innerHTML = data.drivers.map(function(d) { + return '
' + + '
' + escHtml(d.name) + '
' + + '
' + escHtml(d.email) + ' | ' + escHtml(d.phone || '—') + '
' + + 'Vehiculo: ' + escHtml(d.vehicle_type || '—') + ' ' + escHtml(d.vehicle_plate || '') + '
' + + 'Registro: ' + fmtDate(d.registered) + '
' + + 'TODO: documentos (ver CHANGES.md)' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }).join(''); + }); + } + + window.abadApproveDriver = function(uid) { + if (!confirm(lang === 'en' ? 'Approve driver?' : '¿Aprobar conductor?')) return; + apiFetch('drivers/approve', { method: 'POST', body: JSON.stringify({ user_id: uid }) }).then(function(r) { + if (r.ok) { loadDrivers(); loadPending(); loadOverview(); } else alert(r.message || 'Error'); + }); + }; + + window.abadRejectDriver = function(uid, name) { + showReasonModal((lang === 'en' ? 'Reject: ' : 'Rechazar: ') + name, function(reason) { + apiFetch('drivers/reject', { method: 'POST', body: JSON.stringify({ user_id: uid, reason: reason }) }).then(function(r) { + if (r.ok) { loadDrivers(); loadPending(); loadOverview(); } else alert(r.message || 'Error'); + }); + }); + }; + + window.abadSuspendDriver = function(uid, name) { + showReasonModal((lang === 'en' ? 'Suspend: ' : 'Suspender: ') + name, function(reason) { + apiFetch('drivers/suspend', { method: 'POST', body: JSON.stringify({ user_id: uid, reason: reason }) }).then(function(r) { + if (r.ok) loadDrivers(); else alert(r.message || 'Error'); + }); + }); + }; + + /* ── Companies ── */ + function loadCompanies(page) { + if (page) companiesPage = page; + var search = (qs('#company-search') || {}).value || ''; + apiFetch('companies?page=' + companiesPage + '&search=' + encodeURIComponent(search)).then(function(data) { + var tbody = qs('#companies-tbody'); + if (!tbody) return; + if (!data.companies || !data.companies.length) { + tbody.innerHTML = 'Sin empresas'; + return; + } + tbody.innerHTML = data.companies.map(function(c) { + var isActive = c.active == 1; + return '' + + '' + escHtml(c.name) + '
' + escHtml(c.email) + '' + + '' + escHtml(c.contact_name || '—') + '
' + escHtml(c.phone || '') + '' + + '' + (c.trips_count || 0) + '' + + '' + (isActive ? 'Activa' : 'Inactiva') + '' + + '' + + (isActive + ? '' + : '') + + ' ' + + '' + + ''; + }).join(''); + renderPagination('companies-pagination', companiesPage, data.total, data.per_page, loadCompanies); + }); + } + + window.abadActivateCompany = function(cid) { + apiFetch('companies/activate', { method: 'POST', body: JSON.stringify({ company_id: cid }) }).then(function(r) { + if (r.ok) loadCompanies(); else alert(r.message || 'Error'); + }); + }; + + window.abadDeactivateCompany = function(cid) { + if (!confirm(lang === 'en' ? 'Deactivate company?' : '¿Desactivar empresa?')) return; + apiFetch('companies/deactivate', { method: 'POST', body: JSON.stringify({ company_id: cid }) }).then(function(r) { + if (r.ok) loadCompanies(); else alert(r.message || 'Error'); + }); + }; + + window.abadCompanyInvoices = function(cid) { + apiFetch(cid + '/invoices').then(function(data) { + console.log('Facturas empresa ' + cid + ':', data.invoices); + alert(lang === 'en' ? 'Invoices logged to console (' + (data.invoices || []).length + ' records).' : 'Facturas en consola (' + (data.invoices || []).length + ' registros).'); + }); + }; + + /* ── Trips ── */ + function loadTrips(page) { + if (page) tripsPage = page; + var search = (qs('#trip-search') || {}).value || ''; + var from = (qs('#trip-date-from') || {}).value || ''; + var to = (qs('#trip-date-to') || {}).value || ''; + var status = (qs('#trip-status-filter') || {}).value || ''; + var params = 'page=' + tripsPage + '&search=' + encodeURIComponent(search) + '&date_from=' + from + '&date_to=' + to + '&status=' + status; + + apiFetch('trips?' + params).then(function(data) { + var tbody = qs('#trips-tbody'); + if (!tbody) return; + if (!data.trips || !data.trips.length) { tbody.innerHTML = 'Sin viajes'; return; } + + tbody.innerHTML = data.trips.map(function(t) { + return '' + + '' + fmtDate(t.created_at) + '' + + '' + escHtml((t.trip_uuid || '').slice(0,10)) + '…' + + '' + escHtml(t.driver_name || '—') + '' + + '' + escHtml(t.passenger_name || '—') + '' + + '' + fmtMoney(t.fare_total_amount, t.currency) + '' + + '' + escHtml(t.status) + '' + + '' + (t.driver_rating ? '★ ' + t.driver_rating : '—') + '' + + '' + + ''; + }).join(''); + + renderPagination('trips-pagination', tripsPage, data.total, data.per_page, loadTrips); + }); + } + + window.abadTripDetail = function(tid) { + apiFetch('trips/' + tid).then(function(data) { + var trip = data.trip || {}; + show('modal-trip'); + var content = qs('#modal-trip-content'); + if (content) { + content.innerHTML = '
' + + '
Conductor
' + escHtml(trip.driver_name || '—') + '
' + + '
Pasajero
' + escHtml(trip.passenger_name || '—') + '
' + + '
Estado
' + escHtml(trip.status || '—') + '
' + + '
Total
' + fmtMoney(trip.fare_total_amount, trip.currency) + '
' + + '
Comision
' + fmtMoney(trip.platform_fee_amount, trip.currency) + '
' + + '
Neto conductor
'+ fmtMoney(trip.driver_payout_amount, trip.currency) + '
' + + '
Rating
' + (trip.driver_rating ? '★ ' + trip.driver_rating : '—') + '
' + + '
Fecha
' + fmtDate(trip.created_at) + '
' + + '
' + + (data.chat && data.chat.length ? '
Chat
' + + data.chat.map(function(m) { return '
' + escHtml(m.sender) + ': ' + escHtml(m.message) + '
'; }).join('') + + '
' : ''); + } + setTimeout(function() { + var mapEl = qs('#modal-trip-map'); + if (!mapEl || !window.google || !data.positions || !data.positions.length) return; + var mid = data.positions[Math.floor(data.positions.length / 2)]; + tripMap = new google.maps.Map(mapEl, { zoom: 13, center: { lat: parseFloat(mid.lat), lng: parseFloat(mid.lng) }, styles: darkMapStyle() }); + new google.maps.Polyline({ path: data.positions.map(function(p){ return { lat: parseFloat(p.lat), lng: parseFloat(p.lng) }; }), geodesic: true, strokeColor: '#FF6F00', strokeOpacity: 0.9, strokeWeight: 3, map: tripMap }); + }, 150); + }); + }; + + /* ── Incidents ── */ + function loadIncidents(page) { + if (page) incidentsPage = page; + var filter = (qs('#incident-filter') || {}).value || 'active'; + apiFetch('incidents?filter=' + filter + '&page=' + incidentsPage).then(function(data) { + var list = qs('#abad-incidents-list'); + if (!list) return; + if (!data.incidents || !data.incidents.length) { list.innerHTML = '

No hay incidentes.

'; return; } + + list.innerHTML = data.incidents.map(function(inc) { + return '
' + + '
' + + escHtml(inc.driver_name || 'Conductor #' + inc.driver_id) + '
' + + '
' + fmtDate(inc.incident_at) + ' | Estado: ' + escHtml(inc.status) + '' + + (inc.has_audio ? ' | Audio' : '') + + (inc.notes ? '
Nota: ' + escHtml(inc.notes) : '') + '
' + + '
' + + '' + + (inc.status === 'active' ? + '' + + '' : '') + + '
'; + }).join(''); + + renderPagination('incidents-pagination', incidentsPage, data.total, 20, loadIncidents); + }); + } + + window.abadOpenIncident = function(id, lat, lng) { + currentIncidentId = id; + show('modal-incident'); + var mapEl = qs('#modal-incident-map'); + if (mapEl && window.google && lat && lng) { + setTimeout(function() { + incidentMap = new google.maps.Map(mapEl, { zoom: 15, center: { lat: parseFloat(lat), lng: parseFloat(lng) }, styles: darkMapStyle() }); + new google.maps.Marker({ position: { lat: parseFloat(lat), lng: parseFloat(lng) }, map: incidentMap, icon: { path: google.maps.SymbolPath.CIRCLE, scale: 10, fillColor: '#ef4444', fillOpacity: 1, strokeColor: '#fff', strokeWeight: 2 } }); + }, 150); + } + }; + + window.abadResolveIncident = function(id, status) { + var note = (qs('#incident-note') || {}).value || ''; + apiFetch('incidents/' + id + '/resolve', { method: 'POST', body: JSON.stringify({ status: status, note: note }) }).then(function(r) { + if (r.ok) { hide('modal-incident'); loadIncidents(); loadOverview(); } else alert(r.message || 'Error'); + }); + }; + + /* ── Zones ── */ + function loadZones() { + apiFetch('zones').then(function(zones) { + var list = qs('#abad-zones-list'); + if (list) { + list.innerHTML = zones.length ? zones.map(function(z) { + return '
' + + '
' + + escHtml(z.title) + ' [' + escHtml(z.type) + ']
' + + '
Radio: ' + z.radius_km + ' km | Bono: ' + (z.bonus_amount > 0 ? '$'+z.bonus_amount+' '+z.bonus_currency : 'ninguno') + + (z.expires_at ? '
Expira: ' + fmtDate(z.expires_at) : '') + '
' + + '
' + + '' + + (z.active ? '' : '') + + '
'; + }).join('') : '

No hay zonas configuradas.

'; + } + initZonesMap(zones); + }); + } + + function initZonesMap(zones) { + var mapEl = qs('#abad-zones-map'); + if (!mapEl || !window.google) return; + setTimeout(function() { + zonesOverviewMap = new google.maps.Map(mapEl, { zoom: 11, center: { lat: 4.7110, lng: -74.0721 }, styles: darkMapStyle() }); + zones.filter(function(z){ return z.active && z.lat && z.lng; }).forEach(function(z) { + new google.maps.Circle({ center: { lat: parseFloat(z.lat), lng: parseFloat(z.lng) }, radius: parseFloat(z.radius_km)*1000, map: zonesOverviewMap, strokeColor: '#FF6F00', strokeOpacity: 0.7, strokeWeight: 2, fillColor: '#FF6F00', fillOpacity: 0.12 }); + }); + }, 200); + } + + window.abadEditZone = function(id) { + apiFetch('zones').then(function(zones) { + var z = null; + for (var i = 0; i < zones.length; i++) { if (zones[i].id == id) { z = zones[i]; break; } } + if (!z) return; + qs('#zone-id').value = z.id; + qs('#zone-title').value = z.title; + qs('#zone-type').value = z.type; + qs('#zone-description').value = z.description || ''; + qs('#zone-radius').value = z.radius_km; + qs('#zone-bonus').value = z.bonus_amount; + qs('#zone-countdown').value = z.countdown_seconds; + qs('#zone-expires').value = z.expires_at ? z.expires_at.replace(' ','T').slice(0,16) : ''; + qs('#zone-lat').value = z.lat; + qs('#zone-lng').value = z.lng; + show('modal-zone'); + initZoneModal(parseFloat(z.lat), parseFloat(z.lng), parseFloat(z.radius_km)); + }); + }; + + window.abadDeactivateZone = function(id) { + if (!confirm(lang === 'en' ? 'Deactivate zone?' : '¿Desactivar zona?')) return; + apiFetch('zones/' + id + '/deactivate', { method: 'POST' }).then(function(r) { if (r.ok) loadZones(); }); + }; + + function initZoneModal(lat, lng, radiusKm) { + var mapEl = qs('#zone-modal-map'); + if (!mapEl || !window.google) return; + setTimeout(function() { + var center = (lat && lng) ? { lat: lat, lng: lng } : { lat: 4.7110, lng: -74.0721 }; + zoneMap = new google.maps.Map(mapEl, { zoom: 12, center: center, styles: darkMapStyle() }); + if (lat && lng) { + zoneMarker = new google.maps.Marker({ position: center, map: zoneMap }); + zoneCircle = new google.maps.Circle({ center: center, radius: (radiusKm||2)*1000, map: zoneMap, strokeColor: '#FF6F00', strokeOpacity: 0.8, strokeWeight: 2, fillColor: '#FF6F00', fillOpacity: 0.15 }); + } + zoneMap.addListener('click', function(e) { + var pos = { lat: e.latLng.lat(), lng: e.latLng.lng() }; + qs('#zone-lat').value = pos.lat; + qs('#zone-lng').value = pos.lng; + if (zoneMarker) zoneMarker.setMap(null); + if (zoneCircle) zoneCircle.setMap(null); + zoneMarker = new google.maps.Marker({ position: pos, map: zoneMap }); + var r = parseFloat((qs('#zone-radius')||{}).value || 2) * 1000; + zoneCircle = new google.maps.Circle({ center: pos, radius: r, map: zoneMap, strokeColor: '#FF6F00', strokeOpacity: 0.8, strokeWeight: 2, fillColor: '#FF6F00', fillOpacity: 0.15 }); + }); + }, 200); + } + + /* ── Settings ── */ + function loadSettings() { + apiFetch('settings').then(function(s) { + if (qs('#cfg-name')) qs('#cfg-name').value = s.platform_name || ''; + if (qs('#cfg-logo')) qs('#cfg-logo').value = s.platform_logo || ''; + if (qs('#cfg-sos-email')) qs('#cfg-sos-email').value = s.sos_email || ''; + if (qs('#cfg-fee-pct')) qs('#cfg-fee-pct').value = (parseFloat(s.platform_fee_pct||0.20)*100).toFixed(1); + }); + loadFareConfig(); + } + + function loadFareConfig() { + apiFetch('fare-config').then(function(rows) { + var list = qs('#abad-fare-list'); + if (!list) return; + if (!rows.length) { list.innerHTML = '

Sin tarifas.

'; return; } + list.innerHTML = '' + + rows.map(function(r) { + return '' + + '' + + '' + + '' + + '' + + ''; + }).join('') + '
PaisMonedaBasekmminComisionMin.
' + escHtml(r.country_code) + '' + escHtml(r.currency) + '$' + parseFloat(r.base_fare).toFixed(2) + '$' + parseFloat(r.per_km).toFixed(3) + '$' + parseFloat(r.per_minute).toFixed(3) + '' + (parseFloat(r.platform_fee_pct)*100).toFixed(1) + '%$' + parseFloat(r.minimum_fare).toFixed(2) + '
'; + }); + } + + /* ── Reason modal ── */ + function showReasonModal(title, callback) { + reasonCallback = callback; + var el = qs('#modal-reason-title'); + if (el) el.textContent = title; + var txt = qs('#modal-reason-text'); + if (txt) txt.value = ''; + show('modal-reason'); + } + + /* ── Init ── */ + document.addEventListener('DOMContentLoaded', function() { + + document.querySelectorAll('.abad-tab').forEach(function(btn) { + btn.addEventListener('click', function(){ switchTab(btn.dataset.tab); }); + }); + + var langBtn = qs('#abad-lang-btn'); + if (langBtn) langBtn.addEventListener('click', function() { lang = lang === 'es' ? 'en' : 'es'; applyLang(); }); + + var driverSearch = qs('#driver-search'); + if (driverSearch) driverSearch.addEventListener('keydown', function(e){ if(e.key==='Enter'){ driversPage=1; loadDrivers(); } }); + var driverFilter = qs('#driver-filter'); + if (driverFilter) driverFilter.addEventListener('change', function(){ driversPage=1; loadDrivers(); }); + + var btnPending = qs('#btn-show-pending'); + if (btnPending) btnPending.addEventListener('click', function() { + var sec = qs('#abad-pending-section'); + if (sec) sec.style.display = sec.style.display === 'none' ? '' : 'none'; + }); + + var companySearch = qs('#company-search'); + if (companySearch) companySearch.addEventListener('keydown', function(e){ if(e.key==='Enter'){ companiesPage=1; loadCompanies(); } }); + + var btnTripSearch = qs('#btn-trip-search'); + if (btnTripSearch) btnTripSearch.addEventListener('click', function(){ tripsPage=1; loadTrips(); }); + + var btnExportCSV = qs('#btn-export-trips-csv'); + if (btnExportCSV) btnExportCSV.addEventListener('click', function() { + var from = (qs('#trip-date-from')||{}).value||''; + var to = (qs('#trip-date-to')||{}).value||''; + var status = (qs('#trip-status-filter')||{}).value||''; + apiFetch('trips/export-csv?date_from=' + from + '&date_to=' + to + '&status=' + status).then(function(r){ if(r.csv) downloadCSV(r.csv, r.filename||'viajes.csv'); }); + }); + + var incidentFilter = qs('#incident-filter'); + if (incidentFilter) incidentFilter.addEventListener('change', function(){ incidentsPage=1; loadIncidents(); }); + var btnRefreshInc = qs('#btn-refresh-incidents'); + if (btnRefreshInc) btnRefreshInc.addEventListener('click', loadIncidents); + + var incClose = qs('#modal-incident-close'); + if (incClose) incClose.addEventListener('click', function(){ hide('modal-incident'); }); + + var btnAttended = qs('#btn-incident-attended'); + if (btnAttended) btnAttended.addEventListener('click', function(){ if(currentIncidentId) window.abadResolveIncident(currentIncidentId,'attended'); }); + var btnResolved = qs('#btn-incident-resolved'); + if (btnResolved) btnResolved.addEventListener('click', function(){ if(currentIncidentId) window.abadResolveIncident(currentIncidentId,'resolved'); }); + + var tripClose = qs('#modal-trip-close'); + if (tripClose) tripClose.addEventListener('click', function(){ hide('modal-trip'); }); + + var reasonConfirm = qs('#modal-reason-confirm'); + if (reasonConfirm) reasonConfirm.addEventListener('click', function() { + var reason = (qs('#modal-reason-text')||{}).value || ''; + hide('modal-reason'); + if (reasonCallback) { reasonCallback(reason); reasonCallback = null; } + }); + var reasonCancel = qs('#modal-reason-cancel'); + if (reasonCancel) reasonCancel.addEventListener('click', function(){ hide('modal-reason'); reasonCallback=null; }); + + var btnNewZone = qs('#btn-new-zone'); + if (btnNewZone) btnNewZone.addEventListener('click', function() { + ['#zone-id','#zone-title','#zone-description','#zone-expires','#zone-lat','#zone-lng'].forEach(function(s){ var el=qs(s); if(el) el.value=''; }); + qs('#zone-radius').value=2; qs('#zone-bonus').value=0; qs('#zone-countdown').value=180; + show('modal-zone'); + initZoneModal(0,0,2); + }); + var zoneClose = qs('#modal-zone-close'); + var zonCancel = qs('#btn-zone-cancel'); + if (zoneClose) zoneClose.addEventListener('click', function(){ hide('modal-zone'); }); + if (zonCancel) zonCancel.addEventListener('click', function(){ hide('modal-zone'); }); + + var btnZoneSave = qs('#btn-zone-save'); + if (btnZoneSave) btnZoneSave.addEventListener('click', function() { + var lat = parseFloat((qs('#zone-lat')||{}).value||0); + var lng = parseFloat((qs('#zone-lng')||{}).value||0); + var title = (qs('#zone-title')||{}).value||''; + if (!title || !lat || !lng) { alert(lang==='en'?'Click the map and enter a title.':'Haz clic en el mapa y escribe un titulo.'); return; } + var expires = (qs('#zone-expires')||{}).value||''; + var payload = { + id: parseInt((qs('#zone-id')||{}).value||0), + title: title, + description: (qs('#zone-description')||{}).value||'', + type: (qs('#zone-type')||{}).value||'hotspot', + lat: lat, lng: lng, + radius_km: parseFloat((qs('#zone-radius')||{}).value||2), + bonus_amount: parseFloat((qs('#zone-bonus')||{}).value||0), + countdown_seconds: parseInt((qs('#zone-countdown')||{}).value||180), + expires_at: expires ? expires.replace('T',' ') : '', + }; + apiFetch('zones/save', { method: 'POST', body: JSON.stringify(payload) }).then(function(r){ if(r.ok){ hide('modal-zone'); loadZones(); } else alert(r.message||'Error'); }); + }); + + var btnSaveSettings = qs('#btn-save-settings'); + if (btnSaveSettings) btnSaveSettings.addEventListener('click', function() { + var payload = { + platform_name: (qs('#cfg-name')||{}).value||'', + platform_logo: (qs('#cfg-logo')||{}).value||'', + sos_email: (qs('#cfg-sos-email')||{}).value||'', + platform_fee_pct: parseFloat((qs('#cfg-fee-pct')||{}).value||20)/100, + }; + apiFetch('settings/save', { method:'POST', body: JSON.stringify(payload) }).then(function(r){ + if(r.ok){ var el=qs('#cfg-success'); if(el){ el.style.display=''; setTimeout(function(){ el.style.display='none'; }, 2500); } } + }); + }); + + var btnSaveFare = qs('#btn-save-fare'); + if (btnSaveFare) btnSaveFare.addEventListener('click', function() { + var payload = { + country_code: ((qs('#fare-country')||{}).value||'').toUpperCase(), + currency: ((qs('#fare-currency')||{}).value||'USD').toUpperCase(), + base_fare: parseFloat((qs('#fare-base')||{}).value||3), + per_km: parseFloat((qs('#fare-per-km')||{}).value||1.80), + per_minute: parseFloat((qs('#fare-per-min')||{}).value||0.30), + minimum_fare: parseFloat((qs('#fare-minimum')||{}).value||5), + platform_fee_pct: parseFloat((qs('#cfg-fee-pct')||{}).value||20)/100, + }; + apiFetch('fare-config/save', { method:'POST', body: JSON.stringify(payload) }).then(function(r){ + if(r.ok){ loadFareConfig(); var el=qs('#fare-success'); if(el){ el.style.display=''; setTimeout(function(){ el.style.display='none'; }, 2500); } } + else alert(r.message||'Error'); + }); + }); + + applyLang(); + switchTab('overview'); + setInterval(function(){ if(currentTab==='overview') loadOverview(); }, 60000); + }); + + // ── SECURITY TAB ────────────────────────────────────────── + async function loadSecuritySettings() { + try { + const r = await apiFetch('blocked-ips'); + const countEl = document.getElementById('sec-blocked-count'); + if (countEl) countEl.textContent = r.blocked_24h + ' IPs bloqueadas en las últimas 24h'; + const el = document.getElementById('sec-blocked-table'); + if (!el) return; + if (!r.blocked.length) { el.innerHTML = '

Sin IPs bloqueadas.

'; return; } + el.innerHTML = '' + + r.blocked.map(b => '').join('') + + '
IPMotivoExpira
'+b.ip+''+b.reason+''+b.expires_at+'
'; + } catch(e) {} + } + window.absUnblock = async function(id) { await apiFetch('blocked-ips/'+id+'/unblock',{method:'POST'}); loadSecuritySettings(); }; + document.getElementById('btn-save-sec') && document.getElementById('btn-save-sec').addEventListener('click', async function() { + await apiFetch('security-settings/save', {method:'POST', body: JSON.stringify({ + whitelist: document.getElementById('sec-whitelist').value, + hcaptcha_site_key: document.getElementById('sec-hcaptcha-site').value, + hcaptcha_secret: document.getElementById('sec-hcaptcha-secret').value, + })}); + var el = document.getElementById('sec-saved'); el.style.display='block'; setTimeout(function(){el.style.display='none';},3000); + }); + document.querySelectorAll('.abad-tab').forEach(function(btn) { + if (btn.dataset.tab === 'settings') btn.addEventListener('click', loadSecuritySettings); + }); + +})(); diff --git a/autobooking-admin-dashboard.php b/autobooking-admin-dashboard.php new file mode 100644 index 0000000..0443a94 --- /dev/null +++ b/autobooking-admin-dashboard.php @@ -0,0 +1,928 @@ +plugin_url = plugin_dir_url( __FILE__ ); + add_action( 'wp_enqueue_scripts', [ $this, 'enqueue' ] ); + add_action( 'rest_api_init', [ $this, 'register_routes' ] ); + add_shortcode( 'autobooking_admin', [ $this, 'render' ] ); + register_activation_hook( __FILE__, [ $this, 'activate' ] ); + } + + /* ============================================================ + ACTIVACION + ============================================================ */ + public function activate() { + global $wpdb; + $charset = $wpdb->get_charset_collate(); + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_admin_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(60) NOT NULL DEFAULT '', + target_type VARCHAR(40) NOT NULL DEFAULT '', + target_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + meta LONGTEXT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY admin_user_id (admin_user_id), + KEY target (target_type, target_id), + KEY created_at (created_at) + ) $charset;" ); + + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_fare_config ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + country_code CHAR(2) NOT NULL DEFAULT '', + currency CHAR(3) NOT NULL DEFAULT 'USD', + base_fare DECIMAL(10,2) NOT NULL DEFAULT 3.00, + per_km DECIMAL(10,4) NOT NULL DEFAULT 1.80, + per_minute DECIMAL(10,4) NOT NULL DEFAULT 0.30, + platform_fee_pct DECIMAL(5,4) NOT NULL DEFAULT 0.2000, + minimum_fare DECIMAL(10,2) NOT NULL DEFAULT 5.00, + active TINYINT(1) NOT NULL DEFAULT 1, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY country_code (country_code) + ) $charset;" ); + + // Indice en wp_ab_trip_positions (tabla de alto volumen) si no existe + $idx = $wpdb->get_var( "SHOW INDEX FROM {$wpdb->prefix}ab_trip_positions WHERE Key_name = 'trip_ts'" ); + if ( ! $idx ) { + $wpdb->query( "ALTER TABLE {$wpdb->prefix}ab_trip_positions ADD INDEX trip_ts (trip_id, ts)" ); + } + } + + /* ============================================================ + PERMISSION + ============================================================ */ + private function is_admin() { + return is_user_logged_in() && current_user_can( 'manage_autobooking' ); + } + + private function perm() { + if ( ! $this->is_admin() ) { + return new WP_Error( 'forbidden', 'Acceso denegado.', [ 'status' => 403 ] ); + } + return true; + } + + private function audit( $action, $target_type, $target_id, $meta = [] ) { + global $wpdb; + $wpdb->insert( "{$wpdb->prefix}ab_admin_audit", [ + 'admin_user_id' => get_current_user_id(), + 'action' => sanitize_text_field( $action ), + 'target_type' => sanitize_text_field( $target_type ), + 'target_id' => absint( $target_id ), + 'meta' => $meta ? wp_json_encode( $meta ) : null, + 'created_at' => current_time( 'mysql' ), + ] ); + } + + /* ============================================================ + ENQUEUE + ============================================================ */ + public function enqueue() { + global $post; + if ( ! is_a( $post, 'WP_Post' ) || ! has_shortcode( $post->post_content, 'autobooking_admin' ) ) return; + if ( ! $this->is_admin() ) return; + + wp_enqueue_style( 'abad-style', $this->plugin_url . 'assets/admin-dashboard.css', [], self::VERSION ); + wp_enqueue_script( 'google-maps-abad', + 'https://maps.googleapis.com/maps/api/js?key=' . esc_attr( get_option( 'ab_gmaps_key', '' ) ) . '&libraries=places,drawing', + [], null, true + ); + wp_enqueue_script( 'abad-script', $this->plugin_url . 'assets/admin-dashboard.js', [], self::VERSION, true ); + + $user = wp_get_current_user(); + wp_localize_script( 'abad-script', 'AB_ADMIN_CFG', [ + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'api_root' => esc_url_raw( get_rest_url() ), + 'gmaps_key' => esc_attr( get_option( 'ab_gmaps_key', '' ) ), + 'user_id' => $user->ID, + 'user_name' => esc_html( $user->display_name ), + ] ); + } + + /* ============================================================ + REST ROUTES + ============================================================ */ + public function register_routes() { + $ns = self::NS; + $perm = [ $this, 'perm' ]; + + register_rest_route( $ns, '/admin/overview', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_overview' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/drivers', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_drivers' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/drivers/pending', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_drivers_pending' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/drivers/approve', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_approve' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/drivers/reject', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_reject' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/drivers/suspend', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_suspend' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/companies', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_companies' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/companies/activate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_company_activate' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/companies/deactivate',[ 'methods' => 'POST', 'callback' => [ $this, 'rest_company_deactivate' ],'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/companies/(?P\d+)/invoices', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_company_invoices' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/trips', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trips' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/trips/(?P\d+)', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trip_detail' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/trips/export-csv', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trips_csv' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/incidents', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_incidents' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/incidents/(?P\d+)/resolve', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_incident_resolve' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/zones', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_zones' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/zones/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_zone_save' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/zones/(?P\d+)/deactivate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_zone_deactivate' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/settings', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_settings_get' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/settings/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_settings_save' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/fare-config', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_fare_get' ], 'permission_callback' => $perm ] ); + register_rest_route( $ns, '/admin/fare-config/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_fare_save' ], 'permission_callback' => $perm ] ); + } + + /* ============================================================ + OVERVIEW + ============================================================ */ + public function rest_overview( $req ) { + global $wpdb; + $today_start = date( 'Y-m-d 00:00:00' ); + $today_end = date( 'Y-m-d 23:59:59' ); + $week_start = date( 'Y-m-d 00:00:00', strtotime( '-6 days' ) ); + + $trips_today = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE created_at BETWEEN %s AND %s", $today_start, $today_end ) ); + $trips_week = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE created_at >= %s", $week_start ) ); + $active_trips = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE status IN ('assigned','en_route','waiting','in_progress')" ); + $drivers_online = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_driver_status WHERE online = 1" ); + $revenue_today = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(fare_total_amount),0) FROM {$wpdb->prefix}ab_trips WHERE status='finished' AND created_at BETWEEN %s AND %s", $today_start, $today_end ) ); + $sos_open = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_incidents WHERE status='active'" ); + $pending_drivers= (int) ( new WP_User_Query( [ 'role' => 'driver_pending', 'count_total' => true, 'number' => 0 ] ) )->get_total(); + + $chart = $wpdb->get_results( + "SELECT DATE(created_at) AS the_date, COUNT(*) AS total, COALESCE(SUM(fare_total_amount),0) AS revenue + FROM {$wpdb->prefix}ab_trips + WHERE created_at >= NOW() - INTERVAL 30 DAY + GROUP BY DATE(created_at) ORDER BY the_date ASC", + ARRAY_A + ); + + return rest_ensure_response( compact( 'trips_today','trips_week','active_trips','drivers_online','revenue_today','sos_open','pending_drivers' ) + [ 'chart' => $chart ?: [] ] ); + } + + /* ============================================================ + DRIVERS + ============================================================ */ + public function rest_drivers( $req ) { + $search = sanitize_text_field( $req->get_param( 'search' ) ?: '' ); + $filter = sanitize_text_field( $req->get_param( 'filter' ) ?: '' ); + $page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) ); + $per = 20; + + $args = [ 'role' => 'driver', 'number' => $per, 'offset' => ( $page - 1 ) * $per, 'orderby' => 'display_name', 'order' => 'ASC' ]; + if ( $search ) { $args['search'] = '*' . $search . '*'; $args['search_columns'] = [ 'display_name', 'user_email' ]; } + + $query = new WP_User_Query( $args ); + $total = $query->get_total(); + $drivers = []; + + global $wpdb; + foreach ( $query->get_results() as $user ) { + $uid = $user->ID; + $status = $wpdb->get_row( $wpdb->prepare( "SELECT online, last_seen, last_lat, last_lng FROM {$wpdb->prefix}ab_driver_status WHERE driver_id = %d", $uid ) ); + if ( $filter === 'online' && ( ! $status || ! $status->online ) ) continue; + if ( $filter === 'offline' && $status && $status->online ) continue; + + $rating = (float) $wpdb->get_var( $wpdb->prepare( "SELECT AVG(driver_rating) FROM {$wpdb->prefix}ab_trips WHERE driver_id = %d AND driver_rating > 0", $uid ) ); + $trips_total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE driver_id = %d AND status='finished'", $uid ) ); + + $drivers[] = [ + 'id' => $uid, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'phone' => get_user_meta( $uid, 'phone', true ), + 'vehicle_type' => get_user_meta( $uid, 'vehicle_type', true ), + 'vehicle_plate' => get_user_meta( $uid, 'vehicle_plate', true ) ?: get_user_meta( $uid, 'license_plate', true ), + 'photo_url' => get_user_meta( $uid, 'photo_url', true ), + 'online' => $status ? (bool) $status->online : false, + 'last_seen' => $status ? $status->last_seen : null, + 'avg_rating' => round( $rating, 2 ), + 'trips_total' => $trips_total, + 'registered' => $user->user_registered, + ]; + } + return rest_ensure_response( compact( 'drivers', 'total', 'page' ) + [ 'per_page' => $per ] ); + } + + public function rest_drivers_pending( $req ) { + $query = new WP_User_Query( [ 'role' => 'driver_pending', 'number' => 100, 'orderby' => 'registered', 'order' => 'ASC' ] ); + $list = []; + foreach ( $query->get_results() as $user ) { + $uid = $user->ID; + $list[] = [ + 'id' => $uid, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'phone' => get_user_meta( $uid, 'phone', true ), + 'vehicle_type' => get_user_meta( $uid, 'vehicle_type', true ), + 'vehicle_plate' => get_user_meta( $uid, 'vehicle_plate', true ) ?: get_user_meta( $uid, 'license_plate', true ), + 'license_expiry' => get_user_meta( $uid, 'license_expiry', true ), + 'insurance_expiry' => get_user_meta( $uid, 'insurance_expiry', true ), + 'photo_url' => get_user_meta( $uid, 'photo_url', true ), + 'registered' => $user->user_registered, + // TODO: documentos (wp_ab_driver_documents) pendiente de crear - ver CHANGES.md + ]; + } + return rest_ensure_response( [ 'drivers' => $list, 'total' => count( $list ) ] ); + } + + public function rest_driver_approve( $req ) { + $uid = absint( $req->get_param( 'user_id' ) ); + $user = $uid ? get_userdata( $uid ) : null; + if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] ); + if ( ! in_array( 'driver_pending', (array) $user->roles, true ) ) { + return new WP_Error( 'bad_state', 'El usuario no es driver_pending.', [ 'status' => 400 ] ); + } + $user->remove_role( 'driver_pending' ); + $user->add_role( 'driver' ); + $this->audit( 'driver_approve', 'user', $uid ); + return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor aprobado.' ] ); + } + + public function rest_driver_reject( $req ) { + $uid = absint( $req->get_param( 'user_id' ) ); + $reason = sanitize_textarea_field( $req->get_param( 'reason' ) ?: '' ); + $user = $uid ? get_userdata( $uid ) : null; + if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] ); + $user->set_role( 'subscriber' ); + if ( $reason ) update_user_meta( $uid, 'ab_rejection_reason', $reason ); + $this->audit( 'driver_reject', 'user', $uid, [ 'reason' => $reason ] ); + return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor rechazado.' ] ); + } + + public function rest_driver_suspend( $req ) { + global $wpdb; + $uid = absint( $req->get_param( 'user_id' ) ); + $reason = sanitize_textarea_field( $req->get_param( 'reason' ) ?: '' ); + $user = $uid ? get_userdata( $uid ) : null; + if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] ); + $user->set_role( 'subscriber' ); + update_user_meta( $uid, 'ab_suspended', 1 ); + if ( $reason ) update_user_meta( $uid, 'ab_suspension_reason', $reason ); + $wpdb->update( "{$wpdb->prefix}ab_driver_status", [ 'online' => 0 ], [ 'driver_id' => $uid ] ); + $this->audit( 'driver_suspend', 'user', $uid, [ 'reason' => $reason ] ); + return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor suspendido.' ] ); + } + + /* ============================================================ + COMPANIES + ============================================================ */ + public function rest_companies( $req ) { + global $wpdb; + $search = sanitize_text_field( $req->get_param( 'search' ) ?: '' ); + $page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) ); + $per = 20; + $where = '1=1'; + if ( $search ) $where .= $wpdb->prepare( ' AND (name LIKE %s OR email LIKE %s)', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%' ); + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_companies WHERE $where" ); + $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_companies WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d", $per, ( $page - 1 ) * $per ), ARRAY_A ); + + foreach ( $rows as &$row ) { + $cid = (int) $row['id']; + $row['trips_count'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE company_id = %d", $cid ) ); + $wp_uid = $wpdb->get_var( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s LIMIT 1", $cid ) ); + $row['wp_user_id'] = $wp_uid ? (int) $wp_uid : null; + } + unset( $row ); + + return rest_ensure_response( [ 'companies' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per ] ); + } + + public function rest_company_activate( $req ) { + global $wpdb; + $cid = absint( $req->get_param( 'company_id' ) ); + if ( ! $cid ) return new WP_Error( 'bad_request', 'company_id requerido.', [ 'status' => 400 ] ); + $wpdb->update( "{$wpdb->prefix}ab_companies", [ 'active' => 1 ], [ 'id' => $cid ] ); + $user_ids = $wpdb->get_col( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s", $cid ) ); + foreach ( $user_ids as $uid ) { + $u = get_userdata( (int) $uid ); + if ( $u && ! in_array( 'corporate_admin', (array) $u->roles, true ) ) $u->add_role( 'corporate_admin' ); + } + $this->audit( 'company_activate', 'company', $cid ); + return rest_ensure_response( [ 'ok' => true, 'msg' => 'Empresa activada.' ] ); + } + + public function rest_company_deactivate( $req ) { + global $wpdb; + $cid = absint( $req->get_param( 'company_id' ) ); + if ( ! $cid ) return new WP_Error( 'bad_request', 'company_id requerido.', [ 'status' => 400 ] ); + $wpdb->update( "{$wpdb->prefix}ab_companies", [ 'active' => 0 ], [ 'id' => $cid ] ); + $user_ids = $wpdb->get_col( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s", $cid ) ); + foreach ( $user_ids as $uid ) { $u = get_userdata( (int) $uid ); if ( $u ) $u->remove_role( 'corporate_admin' ); } + $this->audit( 'company_deactivate', 'company', $cid ); + return rest_ensure_response( [ 'ok' => true, 'msg' => 'Empresa desactivada.' ] ); + } + + public function rest_company_invoices( $req ) { + global $wpdb; + $cid = absint( $req['id'] ); + $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_invoices WHERE company_id = %d ORDER BY created_at DESC LIMIT 24", $cid ), ARRAY_A ); + return rest_ensure_response( [ 'invoices' => $rows ?: [] ] ); + } + + /* ============================================================ + TRIPS + ============================================================ */ + public function rest_trips( $req ) { + global $wpdb; + $page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) ); + $per = 25; + $status = sanitize_text_field( $req->get_param( 'status' ) ?: '' ); + $driver_id = absint( $req->get_param( 'driver_id' ) ?: 0 ); + $date_from = sanitize_text_field( $req->get_param( 'date_from' ) ?: '' ); + $date_to = sanitize_text_field( $req->get_param( 'date_to' ) ?: '' ); + $search = sanitize_text_field( $req->get_param( 'search' ) ?: '' ); + + $where = '1=1'; + if ( $status ) $where .= $wpdb->prepare( ' AND status = %s', $status ); + if ( $driver_id ) $where .= $wpdb->prepare( ' AND driver_id = %d', $driver_id ); + if ( $date_from ) $where .= $wpdb->prepare( ' AND DATE(created_at) >= %s', $date_from ); + if ( $date_to ) $where .= $wpdb->prepare( ' AND DATE(created_at) <= %s', $date_to ); + if ( $search ) $where .= $wpdb->prepare( ' AND (passenger_name LIKE %s OR driver_name LIKE %s OR trip_uuid LIKE %s)', + '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%' + ); + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE $where" ); + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT id, trip_uuid, status, driver_id, driver_name, passenger_name, + fare_total_amount, platform_fee_amount, driver_payout_amount, + currency, driver_rating, created_at, updated_at + FROM {$wpdb->prefix}ab_trips WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d", + $per, ( $page - 1 ) * $per + ), ARRAY_A ); + + return rest_ensure_response( [ 'trips' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per ] ); + } + + public function rest_trip_detail( $req ) { + global $wpdb; + $id = absint( $req['id'] ); + $trip = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_trips WHERE id = %d", $id ), ARRAY_A ); + if ( ! $trip ) return new WP_Error( 'not_found', 'Viaje no encontrado.', [ 'status' => 404 ] ); + + // Posiciones acotadas a 500 para rendimiento (tabla de alto volumen) + $positions = $wpdb->get_results( $wpdb->prepare( + "SELECT lat, lng, ts FROM {$wpdb->prefix}ab_trip_positions WHERE trip_id = %d ORDER BY ts ASC LIMIT 500", + $id + ), ARRAY_A ); + + $chat = $wpdb->get_results( $wpdb->prepare( + "SELECT sender, message, created_at FROM {$wpdb->prefix}autobooking_chat WHERE ride_id = %d ORDER BY created_at ASC LIMIT 200", + $id + ), ARRAY_A ); + + return rest_ensure_response( [ 'trip' => $trip, 'positions' => $positions ?: [], 'chat' => $chat ?: [] ] ); + } + + public function rest_trips_csv( $req ) { + global $wpdb; + $date_from = sanitize_text_field( $req->get_param( 'date_from' ) ?: '' ); + $date_to = sanitize_text_field( $req->get_param( 'date_to' ) ?: '' ); + $status = sanitize_text_field( $req->get_param( 'status' ) ?: '' ); + $where = '1=1'; + if ( $status ) $where .= $wpdb->prepare( ' AND status = %s', $status ); + if ( $date_from ) $where .= $wpdb->prepare( ' AND DATE(created_at) >= %s', $date_from ); + if ( $date_to ) $where .= $wpdb->prepare( ' AND DATE(created_at) <= %s', $date_to ); + + $rows = $wpdb->get_results( + "SELECT id, trip_uuid, created_at, driver_name, passenger_name, fare_total_amount, platform_fee_amount, driver_payout_amount, currency, status, driver_rating + FROM {$wpdb->prefix}ab_trips WHERE $where ORDER BY created_at DESC LIMIT 10000", + ARRAY_A + ); + $csv = '"ID","UUID","Fecha","Conductor","Pasajero","Total","Comision","Neto","Moneda","Estado","Rating"' . "\n"; + foreach ( $rows as $r ) { + $csv .= implode( ',', array_map( fn($c) => '"' . str_replace( '"', '""', (string) $c ) . '"', array_values( $r ) ) ) . "\n"; + } + return rest_ensure_response( [ 'csv' => $csv, 'filename' => 'viajes-' . date( 'Y-m-d' ) . '.csv' ] ); + } + + /* ============================================================ + INCIDENTS + ============================================================ */ + public function rest_incidents( $req ) { + global $wpdb; + $filter = sanitize_text_field( $req->get_param( 'filter' ) ?: 'active' ); + $page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) ); + $per = 20; + $where = '1=1'; + if ( $filter && $filter !== 'all' ) $where .= $wpdb->prepare( ' AND i.status = %s', $filter ); + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_incidents i WHERE $where" ); + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT i.*, u.display_name AS driver_name + FROM {$wpdb->prefix}ab_incidents i + LEFT JOIN {$wpdb->users} u ON u.ID = i.driver_id + WHERE $where ORDER BY (i.status = 'active') DESC, i.incident_at DESC + LIMIT %d OFFSET %d", + $per, ( $page - 1 ) * $per + ), ARRAY_A ); + + $upload_dir = wp_upload_dir(); + foreach ( $rows as &$row ) { + $dir = $upload_dir['basedir'] . '/ab-incidents/' . $row['id']; + $row['has_audio'] = is_dir( $dir ) && ! empty( glob( $dir . '/*.webm' ) ); + } + unset( $row ); + + return rest_ensure_response( [ 'incidents' => $rows ?: [], 'total' => $total, 'page' => $page ] ); + } + + public function rest_incident_resolve( $req ) { + global $wpdb; + $id = absint( $req['id'] ); + $note = sanitize_textarea_field( $req->get_param( 'note' ) ?: '' ); + $status = in_array( $req->get_param( 'status' ), [ 'resolved', 'attended' ], true ) ? $req->get_param( 'status' ) : 'resolved'; + $inc = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}ab_incidents WHERE id = %d", $id ) ); + if ( ! $inc ) return new WP_Error( 'not_found', 'Incidente no encontrado.', [ 'status' => 404 ] ); + $wpdb->update( "{$wpdb->prefix}ab_incidents", [ 'status' => $status, 'resolved_at' => current_time( 'mysql' ), 'notes' => $note ], [ 'id' => $id ] ); + $this->audit( 'incident_' . $status, 'incident', $id, [ 'note' => $note ] ); + return rest_ensure_response( [ 'ok' => true ] ); + } + + /* ============================================================ + ZONES + ============================================================ */ + public function rest_zones( $req ) { + global $wpdb; + return rest_ensure_response( $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}ab_zone_alerts ORDER BY active DESC, created_at DESC", ARRAY_A ) ?: [] ); + } + + public function rest_zone_save( $req ) { + global $wpdb; + $body = $req->get_json_params(); + $id = absint( $body['id'] ?? 0 ); + $title = sanitize_text_field( $body['title'] ?? '' ); + $lat = (float) ( $body['lat'] ?? 0 ); + $lng = (float) ( $body['lng'] ?? 0 ); + $type = in_array( $body['type'] ?? '', [ 'hotspot', 'bonus', 'warning' ], true ) ? $body['type'] : 'hotspot'; + + if ( ! $title || ! $lat || ! $lng ) return new WP_Error( 'missing', 'Titulo, lat y lng son requeridos.', [ 'status' => 400 ] ); + + $data = [ + 'title' => $title, + 'description' => sanitize_textarea_field( $body['description'] ?? '' ), + 'lat' => $lat, + 'lng' => $lng, + 'radius_km' => max( 0.1, (float) ( $body['radius_km'] ?? 2 ) ), + 'bonus_amount' => max( 0, (float) ( $body['bonus_amount'] ?? 0 ) ), + 'bonus_currency' => sanitize_text_field( $body['bonus_currency'] ?? 'USD' ), + 'countdown_seconds' => max( 60, (int) ( $body['countdown_seconds'] ?? 180 ) ), + 'type' => $type, + 'active' => 1, + 'expires_at' => sanitize_text_field( $body['expires_at'] ?? '' ) ?: null, + ]; + + if ( $id ) { + $wpdb->update( "{$wpdb->prefix}ab_zone_alerts", $data, [ 'id' => $id ] ); + $this->audit( 'zone_update', 'zone', $id ); + } else { + $data['created_at'] = current_time( 'mysql' ); + $wpdb->insert( "{$wpdb->prefix}ab_zone_alerts", $data ); + $id = $wpdb->insert_id; + $this->audit( 'zone_create', 'zone', $id ); + } + return rest_ensure_response( [ 'ok' => true, 'id' => $id ] ); + } + + public function rest_zone_deactivate( $req ) { + global $wpdb; + $id = absint( $req['id'] ); + $wpdb->update( "{$wpdb->prefix}ab_zone_alerts", [ 'active' => 0 ], [ 'id' => $id ] ); + $this->audit( 'zone_deactivate', 'zone', $id ); + return rest_ensure_response( [ 'ok' => true ] ); + } + + /* ============================================================ + SETTINGS & FARE CONFIG + ============================================================ */ + public function rest_settings_get( $req ) { + return rest_ensure_response( [ + 'platform_name' => get_option( 'ab_platform_name', 'AutoBooking' ), + 'platform_logo' => get_option( 'ab_platform_logo', '' ), + 'sos_email' => get_option( 'ab_sos_email', get_option( 'admin_email' ) ), + 'gmaps_key' => get_option( 'ab_gmaps_key', '' ), + 'platform_fee_pct' => (float) get_option( 'ab_platform_fee_pct', 0.20 ), + ] ); + } + + public function rest_settings_save( $req ) { + $body = $req->get_json_params(); + if ( isset( $body['platform_name'] ) ) update_option( 'ab_platform_name', sanitize_text_field( $body['platform_name'] ) ); + if ( isset( $body['platform_logo'] ) ) update_option( 'ab_platform_logo', esc_url_raw( $body['platform_logo'] ) ); + if ( isset( $body['sos_email'] ) ) update_option( 'ab_sos_email', sanitize_email( $body['sos_email'] ) ); + if ( isset( $body['gmaps_key'] ) ) update_option( 'ab_gmaps_key', sanitize_text_field( $body['gmaps_key'] ) ); + if ( isset( $body['platform_fee_pct'] ) ) update_option( 'ab_platform_fee_pct', max( 0, min( 1, (float) $body['platform_fee_pct'] ) ) ); + $this->audit( 'settings_save', 'settings', 0 ); + return rest_ensure_response( [ 'ok' => true ] ); + } + + public function rest_fare_get( $req ) { + global $wpdb; + return rest_ensure_response( $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}ab_fare_config ORDER BY country_code ASC", ARRAY_A ) ?: [] ); + } + + public function rest_fare_save( $req ) { + global $wpdb; + $body = $req->get_json_params(); + $country_code = strtoupper( sanitize_text_field( $body['country_code'] ?? '' ) ); + if ( strlen( $country_code ) !== 2 ) return new WP_Error( 'bad_request', 'country_code debe ser 2 letras ISO 3166.', [ 'status' => 400 ] ); + + $data = [ + 'country_code' => $country_code, + 'currency' => strtoupper( sanitize_text_field( $body['currency'] ?? 'USD' ) ), + 'base_fare' => max( 0, (float) ( $body['base_fare'] ?? 3 ) ), + 'per_km' => max( 0, (float) ( $body['per_km'] ?? 1.80 ) ), + 'per_minute' => max( 0, (float) ( $body['per_minute'] ?? 0.30 ) ), + 'platform_fee_pct' => max( 0, min( 1, (float) ( $body['platform_fee_pct'] ?? 0.20 ) ) ), + 'minimum_fare' => max( 0, (float) ( $body['minimum_fare'] ?? 5 ) ), + 'active' => 1, + 'updated_at' => current_time( 'mysql' ), + ]; + $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}ab_fare_config WHERE country_code = %s", $country_code ) ); + if ( $existing ) $wpdb->update( "{$wpdb->prefix}ab_fare_config", $data, [ 'country_code' => $country_code ] ); + else $wpdb->insert( "{$wpdb->prefix}ab_fare_config", $data ); + $this->audit( 'fare_save', 'fare_config', 0, [ 'country' => $country_code ] ); + return rest_ensure_response( [ 'ok' => true ] ); + } + + /* ============================================================ + SHORTCODE RENDER + ============================================================ */ + public function render( $atts ) { + if ( ! is_user_logged_in() ) { wp_redirect( wp_login_url( get_permalink() ) ); exit; } + if ( ! current_user_can( 'manage_autobooking' ) ) { + return '
+

Acceso Restringido

+

Esta seccion es exclusiva para administradores de la plataforma.

+ Cerrar sesion +
'; + } + + $user = wp_get_current_user(); + $logo = get_option( 'ab_platform_logo', '' ); + $plat_name = esc_html( get_option( 'ab_platform_name', 'AutoBooking' ) ); + + ob_start(); + ?> +
+ +
+
+ + + +
+ +
+ +
+ + Admin Panel +
+
+
+ + + display_name ); ?> +
+
+ + + + +
+
+
+
+
+
Viajes hoy
+
+
+
+
+
Esta semana
+
+
Activos ahora
+
Conductores online
+
+
+
+
Activos ahora
+
+
+
+
+
Conductores online
+
+
+
+
+
Ingresos hoy
+
+
+
+
+
SOS activos
+
+
+
+
+
Pendientes
+
+
+
+
+ Viajes — Ultimos 30 dias +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ true, + 'dispatch_operations' => true, + ]); + } + } + + public function can_access() { + return is_user_logged_in() && ( + current_user_can('manage_autobooking') || + current_user_can('dispatch_operations') || + current_user_can('manage_options') + ); + } + + /* ── REST endpoints ──────────────────────────────────────── */ + + public function register_endpoints() { + $ns = 'autobooking/v1'; + $auth = [$this, 'can_access']; + register_rest_route($ns, '/command/stats', ['methods' => 'GET', 'callback' => [$this, 'ep_stats'], 'permission_callback' => $auth]); + register_rest_route($ns, '/command/live-map', ['methods' => 'GET', 'callback' => [$this, 'ep_live_map'], 'permission_callback' => $auth]); + register_rest_route($ns, '/command/alerts', ['methods' => 'GET', 'callback' => [$this, 'ep_alerts'], 'permission_callback' => $auth]); + register_rest_route($ns, '/command/trip/(?P\d+)', ['methods' => 'GET', 'callback' => [$this, 'ep_trip'], 'permission_callback' => $auth]); + register_rest_route($ns, '/command/incident', ['methods' => 'POST', 'callback' => [$this, 'ep_incident'], 'permission_callback' => $auth]); + register_rest_route($ns, '/command/resolve/(?P\d+)',['methods' => 'POST', 'callback' => [$this, 'ep_resolve'], 'permission_callback' => $auth]); + register_rest_route($ns, '/command/block-driver', ['methods' => 'POST', 'callback' => [$this, 'ep_block_driver'], 'permission_callback' => $auth]); + register_rest_route($ns, '/command/message', ['methods' => 'POST', 'callback' => [$this, 'ep_message'], 'permission_callback' => $auth]); + } + + public function ep_stats() { + global $wpdb; + return rest_ensure_response([ + 'active_trips' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_trips WHERE status IN ('active','en_route','picking_up','in_progress')"), + 'available_drivers' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_driver_status WHERE online=1 AND last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"), + 'open_sos' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_incidents WHERE status IN ('open','active','pending')"), + 'trips_today' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_trips WHERE DATE(created_at) = CURDATE()"), + ]); + } + + public function ep_live_map() { + global $wpdb; + $trips = $wpdb->get_results(" + SELECT t.id, t.status, t.driver_id, t.passenger_id, + t.driver_name, t.passenger_name, + t.pickup_time AS started_at, + t.dropoff_lat, t.dropoff_lng, + p.lat AS driver_lat, p.lng AS driver_lng + FROM wp_ab_trips t + LEFT JOIN ( + SELECT trip_id, lat, lng + FROM wp_ab_trip_positions + WHERE id IN (SELECT MAX(id) FROM wp_ab_trip_positions GROUP BY trip_id) + ) p ON t.id = p.trip_id + WHERE t.status IN ('active','en_route','picking_up','in_progress') + "); + $drivers = $wpdb->get_results(" + SELECT ds.driver_id AS user_id, ds.last_lat AS lat, ds.last_lng AS lng, u.display_name + FROM wp_ab_driver_status ds + JOIN wp_users u ON ds.driver_id = u.ID + WHERE ds.online = 1 + AND ds.last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE) + "); + return rest_ensure_response(['trips' => $trips, 'drivers' => $drivers]); + } + + public function ep_alerts() { + global $wpdb; + $sos = $wpdb->get_results(" + SELECT i.id, i.trip_id, i.user_id, i.type, i.description, + i.lat, i.lng, i.status, i.created_at, + u.display_name AS user_name, + t.driver_id, t.passenger_id + FROM wp_ab_incidents i + LEFT JOIN wp_users u ON i.user_id = u.ID + LEFT JOIN wp_ab_trips t ON i.trip_id = t.id + WHERE i.status IN ('open','active','pending') + ORDER BY i.created_at DESC + LIMIT 30 + "); + return rest_ensure_response(['sos' => $sos, 'count' => count($sos)]); + } + + public function ep_trip($req) { + global $wpdb; + $id = (int)$req['id']; + $trip = $wpdb->get_row($wpdb->prepare(" + SELECT t.*, + du.display_name AS driver_name, + pu.display_name AS passenger_name, + dm.meta_value AS driver_phone, + pm.meta_value AS passenger_phone + FROM wp_ab_trips t + LEFT JOIN wp_users du ON t.driver_id = du.ID + LEFT JOIN wp_users pu ON t.passenger_id = pu.ID + LEFT JOIN wp_usermeta dm ON t.driver_id = dm.user_id AND dm.meta_key = 'phone' + LEFT JOIN wp_usermeta pm ON t.passenger_id = pm.user_id AND pm.meta_key = 'phone' + WHERE t.id = %d + ", $id)); + if (!$trip) return new WP_Error('not_found', 'Trip not found', ['status' => 404]); + + // Posición actual del conductor + $cur = $wpdb->get_row($wpdb->prepare( + "SELECT lat, lng FROM wp_ab_trip_positions WHERE trip_id = %d ORDER BY id DESC LIMIT 1", $id + )); + $trip->current_lat = $cur ? $cur->lat : null; + $trip->current_lng = $cur ? $cur->lng : null; + + // Enmascarar teléfonos según rol + $trip->driver_phone = ab_mask_phone($trip->driver_phone ?? ''); + $trip->passenger_phone = ab_mask_phone($trip->passenger_phone ?? ''); + + $incidents = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM wp_ab_incidents WHERE trip_id = %d ORDER BY created_at DESC LIMIT 10", $id + )); + return rest_ensure_response(['trip' => $trip, 'incidents' => $incidents]); + } + + public function ep_incident($req) { + global $wpdb; + $p = $req->get_params(); + $trip_id = (int)($p['trip_id'] ?? 0); + $type = sanitize_text_field($p['type'] ?? 'operator_action'); + $desc = sanitize_textarea_field($p['description'] ?? ''); + $protocol = sanitize_text_field($p['protocol'] ?? ''); + $wpdb->insert('wp_ab_incidents', [ + 'trip_id' => $trip_id, + 'user_id' => get_current_user_id(), + 'type' => $type, + 'description' => $desc . ($protocol ? " [PROTOCOLO: {$protocol}]" : ''), + 'status' => 'active', + 'created_at' => current_time('mysql'), + ]); + $this->audit('command_incident', compact('trip_id', 'type', 'protocol')); + return rest_ensure_response(['success' => true, 'id' => $wpdb->insert_id]); + } + + public function ep_resolve($req) { + global $wpdb; + $id = (int)$req['id']; + $notes = sanitize_textarea_field($req->get_param('notes') ?? ''); + $wpdb->update('wp_ab_incidents', [ + 'status' => 'resolved', + 'resolved_at' => current_time('mysql'), + 'resolution_notes' => $notes, + ], ['id' => $id]); + $this->audit('command_resolve', ['alert_id' => $id, 'notes' => $notes]); + return rest_ensure_response(['success' => true]); + } + + public function ep_block_driver($req) { + global $wpdb; + $driver_id = (int)($req->get_param('driver_id') ?? 0); + $reason = sanitize_text_field($req->get_param('reason') ?? ''); + if (!$driver_id) return new WP_Error('invalid', 'Driver ID required', ['status' => 400]); + $wpdb->update('wp_ab_driver_status', ['online' => 0], ['driver_id' => $driver_id]); + $this->audit('command_block_driver', compact('driver_id', 'reason')); + return rest_ensure_response(['success' => true]); + } + + public function ep_message($req) { + global $wpdb; + $trip_id = (int)($req->get_param('trip_id') ?? 0); + $to_user_id = (int)($req->get_param('to_user_id') ?? 0); + $message = sanitize_textarea_field($req->get_param('message') ?? ''); + if (!$message) return new WP_Error('invalid', 'Message required', ['status' => 400]); + $wpdb->insert('wp_autobooking_chat', [ + 'trip_id' => $trip_id, + 'from_user_id' => get_current_user_id(), + 'to_user_id' => $to_user_id, + 'message' => '[OPERADOR] ' . $message, + 'created_at' => current_time('mysql'), + ]); + return rest_ensure_response(['success' => true]); + } + + private function audit($action, $details) { + global $wpdb; + $wpdb->insert('wp_ab_admin_audit', [ + 'admin_id' => get_current_user_id(), + 'action' => $action, + 'details' => wp_json_encode($details), + 'created_at' => current_time('mysql'), + ]); + } + + /* ── Assets ──────────────────────────────────────────────── */ + + public function enqueue() { + global $post; + if (!$post || !has_shortcode($post->post_content, 'autobooking_command_center')) return; + if (!$this->can_access()) return; + $url = plugin_dir_url(__FILE__); + wp_enqueue_style('cc-style', $url . 'command-center.css', [], '1.0.0'); + wp_enqueue_script('cc-script', $url . 'command-center.js', + ['jquery'], '1.0.0', true + ); + wp_localize_script('cc-script', 'ccConfig', [ + 'rest' => rest_url('autobooking/v1/'), + 'nonce' => wp_create_nonce('wp_rest'), + 'userId' => get_current_user_id(), + ]); + } + + /* ── Shortcode HTML ──────────────────────────────────────── */ + + public function render() { + if (!$this->can_access()) + return '

Acceso no autorizado.

'; + ob_start(); ?> +
+ + +
+
+ + + + + COMANDO CENTRAL +
+
+
--Viajes activos
+
--Conductores libres
+
0Alertas SOS
+
--Viajes hoy
+
+
+ + + +
+
--:--:--
+
+ + +
+ + +
+
+
+ En viaje + Disponible + SOS +
+
+ + +
+ + +
+
+ + + + + + ALERTAS SOS + 0 +
+
Sin alertas activas
+
+ + +
+
+ + + + + + + VIAJES ACTIVOS +
+
Sin viajes activos
+
+ + + + +
+
+ + +
+
+

Activar Protocolo de Seguridad

+
+ + + + + + +
+ +
+ + +
+
+
+ + +
+
+

Enviar Mensaje del Operador

+ + +
+ + +
+
+
+ +
+ get_charset_collate(); + + $sql = []; + + $sql[] = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_incidents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + driver_id BIGINT NOT NULL, + trip_id BIGINT NULL, + lat DECIMAL(10,7) NOT NULL DEFAULT 0, + lng DECIMAL(10,7) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + chunks_count INT NOT NULL DEFAULT 0, + incident_at DATETIME NOT NULL, + resolved_at DATETIME NULL, + notes TEXT NULL, + PRIMARY KEY (id), + KEY driver_id (driver_id) + ) $charset;"; + + $sql[] = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_zone_alerts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(160) NOT NULL DEFAULT '', + description TEXT NULL, + lat DECIMAL(10,7) NOT NULL DEFAULT 0, + lng DECIMAL(10,7) NOT NULL DEFAULT 0, + radius_km DECIMAL(5,2) NOT NULL DEFAULT 1.00, + bonus_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + bonus_currency CHAR(3) NOT NULL DEFAULT 'USD', + countdown_seconds INT NOT NULL DEFAULT 180, + type VARCHAR(20) NOT NULL DEFAULT 'hotspot', + active TINYINT(1) NOT NULL DEFAULT 1, + expires_at DATETIME NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id) + ) $charset;"; + + $sql[] = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_zone_alert_responses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + zone_id BIGINT NOT NULL, + driver_id BIGINT NOT NULL, + response VARCHAR(20) NOT NULL DEFAULT '', + responded_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY zone_driver (zone_id, driver_id) + ) $charset;"; + + $sql[] = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_scheduled_trips ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + trip_uuid VARCHAR(64) NOT NULL DEFAULT '', + driver_id BIGINT NOT NULL, + passenger_name VARCHAR(160) NOT NULL DEFAULT '', + pickup_time DATETIME NOT NULL, + pickup_address VARCHAR(255) NOT NULL DEFAULT '', + dropoff_address VARCHAR(255) NOT NULL DEFAULT '', + estimated_fare DECIMAL(10,2) NOT NULL DEFAULT 0, + currency CHAR(3) NOT NULL DEFAULT 'USD', + confirmed TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY driver_id (driver_id) + ) $charset;"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + foreach ( $sql as $s ) { + dbDelta( $s ); + } + } + + /* ───────────────────────────────────────────────────────────── + ENQUEUE + ───────────────────────────────────────────────────────────── */ + public function enqueue() { + global $post; + if ( ! is_a( $post, 'WP_Post' ) || ! has_shortcode( $post->post_content, 'autobooking_driver_dashboard' ) ) { + return; + } + + $base = plugin_dir_url( __FILE__ ); + $ver = self::VERSION; + + // Google Maps + // Google Maps API key is stored in wp_options (ab_gmaps_key) and set via Admin → Config. + // NEVER hardcode the key here — read it from the database at runtime. + $gmaps_key = get_option( 'ab_gmaps_key', '' ); + + // CSS + wp_enqueue_style( + 'abd-dashboard', + $base . 'assets/driver-dashboard.css', + [], + $ver + ); + + // JS + wp_enqueue_script( + 'abd-dashboard', + $base . 'assets/driver-dashboard.js', + [], + $ver, + true + ); + + // Build driver data from user meta + $user = wp_get_current_user(); + $uid = $user->ID; + + $driver_name = get_user_meta( $uid, 'driver_name', true ); + if ( ! $driver_name ) { + $driver_name = get_user_meta( $uid, 'full_name', true ); + } + if ( ! $driver_name ) { + $fn = get_user_meta( $uid, 'first_name', true ); + $ln = get_user_meta( $uid, 'last_name', true ); + $driver_name = trim( "$fn $ln" ); + } + if ( ! $driver_name ) { + $driver_name = $user->display_name; + } + + $photo = get_user_meta( $uid, 'photo_url', true ); + if ( ! $photo ) { + $photo = get_user_meta( $uid, 'driver_photo', true ); + } + + $vehicle_type = get_user_meta( $uid, 'vehicle_type', true ); + $vehicle_plate = get_user_meta( $uid, 'vehicle_plate', true ); + if ( ! $vehicle_plate ) { + $vehicle_plate = get_user_meta( $uid, 'license_plate', true ); + } + $insurance_expiry = get_user_meta( $uid, 'insurance_expiry', true ); + $license_expiry = get_user_meta( $uid, 'license_expiry', true ); + $inspection_expiry = get_user_meta( $uid, 'inspection_expiry', true ); + + wp_localize_script( 'abd-dashboard', 'AB_DRIVER_CFG', [ + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'api_root' => esc_url_raw( get_rest_url() ), + 'user_id' => $uid, + 'gmaps_key' => $gmaps_key, + 'driver' => [ + 'name' => $driver_name, + 'photo' => $photo ?: get_avatar_url( $uid, [ 'size' => 112 ] ) ?: '', + 'vehicle_type' => $vehicle_type ?: '', + 'vehicle_plate' => $vehicle_plate ?: '', + 'insurance_expiry' => $insurance_expiry ?: '', + 'license_expiry' => $license_expiry ?: '', + 'inspection_expiry' => $inspection_expiry ?: '', + 'email' => $user->user_email, + 'phone' => get_user_meta( $uid, 'phone', true ) ?: get_user_meta( $uid, 'billing_phone', true ) ?: '', + ], + ] ); + } + + /* ───────────────────────────────────────────────────────────── + REST ROUTES + ───────────────────────────────────────────────────────────── */ + public function register_routes() { + $ns = self::NAMESPACE; + $auth = [ 'permission_callback' => [ $this, 'is_driver' ] ]; + + // GET/POST driver status + register_rest_route( $ns, '/driver/status', [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_get_status' ], + 'permission_callback' => [ $this, 'is_driver' ], + ], + [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_post_status' ], + 'permission_callback' => [ $this, 'is_driver' ], + ], + ] ); + + // GET current trip + register_rest_route( $ns, '/driver/current-trip', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_current_trip' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // GET earnings + register_rest_route( $ns, '/driver/earnings', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_earnings' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // GET metrics + register_rest_route( $ns, '/driver/metrics', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_metrics' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // GET history + register_rest_route( $ns, '/driver/history', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_history' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // GET scheduled + register_rest_route( $ns, '/driver/scheduled', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_scheduled' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // GET profile + register_rest_route( $ns, '/driver/profile', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_profile' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // GET zones + register_rest_route( $ns, '/driver/zones', [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_zones' ], + 'permission_callback' => '__return_true', + ] ); + + // POST zone-response + register_rest_route( $ns, '/driver/zone-response', [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_zone_response' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // POST incident/sos + register_rest_route( $ns, '/incident/sos', [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_sos' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // POST telemetry/position + register_rest_route( $ns, '/telemetry/position', [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_position' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // POST trip/start + register_rest_route( $ns, '/trip/start', [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_trip_start' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + // POST trip/finish + register_rest_route( $ns, '/trip/finish', [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_trip_finish' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + register_rest_route( $ns, '/driver/change-password', [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_change_password' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + + register_rest_route( $ns, '/driver/update-profile', [ + 'methods' => 'POST', + 'callback' => [ $this, 'rest_update_profile' ], + 'permission_callback' => [ $this, 'is_driver' ], + ] ); + } + + /* ───────────────────────────────────────────────────────────── + PERMISSION + ───────────────────────────────────────────────────────────── */ + public function is_driver() { + if ( ! is_user_logged_in() ) return new WP_Error( 'not_logged_in', 'Authentication required.', [ 'status' => 401 ] ); + $user = wp_get_current_user(); + if ( in_array( 'driver', (array) $user->roles, true ) || + in_array( 'administrator', (array) $user->roles, true ) ) { + return true; + } + return new WP_Error( 'not_driver', 'Access denied.', [ 'status' => 403 ] ); + } + + /* ───────────────────────────────────────────────────────────── + REST HANDLERS + ───────────────────────────────────────────────────────────── */ + + // GET /driver/status + public function rest_get_status( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT online, last_seen, current_trip_id FROM {$wpdb->prefix}ab_driver_status WHERE driver_id = %d", + $uid + ) ); + if ( ! $row ) { + return rest_ensure_response( [ 'online' => false, 'last_seen' => null, 'current_trip_id' => null ] ); + } + return rest_ensure_response( [ + 'online' => (bool) $row->online, + 'last_seen' => $row->last_seen, + 'current_trip_id' => $row->current_trip_id, + ] ); + } + + // POST /driver/status + public function rest_post_status( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $body = $req->get_json_params(); + $online = ! empty( $body['online'] ); + $lat = isset( $body['lat'] ) ? floatval( $body['lat'] ) : null; + $lng = isset( $body['lng'] ) ? floatval( $body['lng'] ) : null; + $now = current_time( 'mysql' ); + + $exists = $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_driver_status WHERE driver_id = %d", $uid + ) ); + + $data = [ + 'online' => $online ? 1 : 0, + 'last_seen' => $now, + ]; + if ( $lat !== null ) $data['last_lat'] = $lat; + if ( $lng !== null ) $data['last_lng'] = $lng; + + if ( $exists ) { + $wpdb->update( "{$wpdb->prefix}ab_driver_status", $data, [ 'driver_id' => $uid ] ); + } else { + $data['driver_id'] = $uid; + $wpdb->insert( "{$wpdb->prefix}ab_driver_status", $data ); + } + + // Manage sessions + if ( $online ) { + // Insert new session (go online) + $sess = [ + 'driver_id' => $uid, + 'started_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if ( $lat !== null ) { $sess['start_lat'] = $lat; $sess['start_lng'] = $lng; } + $wpdb->insert( "{$wpdb->prefix}ab_driver_sessions", $sess ); + } else { + // Close latest open session + $sess_id = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}ab_driver_sessions WHERE driver_id = %d AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1", + $uid + ) ); + if ( $sess_id ) { + $upd = [ 'ended_at' => $now, 'updated_at' => $now ]; + if ( $lat !== null ) { $upd['end_lat'] = $lat; $upd['end_lng'] = $lng; } + // Calculate online_seconds + $start = $wpdb->get_var( $wpdb->prepare( + "SELECT started_at FROM {$wpdb->prefix}ab_driver_sessions WHERE id = %d", $sess_id + ) ); + if ( $start ) { + $upd['online_seconds'] = max( 0, strtotime( $now ) - strtotime( $start ) ); + } + $wpdb->update( "{$wpdb->prefix}ab_driver_sessions", $upd, [ 'id' => $sess_id ] ); + } + } + + return rest_ensure_response( [ 'ok' => true, 'online' => $online ] ); + } + + // GET /driver/current-trip + public function rest_current_trip( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $statuses = "'assigned','en_route','waiting','in_progress'"; + + $trip = $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status IN ($statuses) + ORDER BY updated_at DESC LIMIT 1", + $uid + ), ARRAY_A ); + + if ( ! $trip ) { + return rest_ensure_response( null ); + } + + // Unread chat count + $unread = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}autobooking_chat + WHERE ride_id = %d AND sender != 'driver'", + $trip['id'] + ) ); + $trip['unread_count'] = $unread; + + return rest_ensure_response( $trip ); + } + + // GET /driver/earnings + public function rest_earnings( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $period = $req->get_param( 'period' ) ?: 'day'; + + switch ( $period ) { + case 'week': + $where_date = "AND DATE(created_at) >= CURDATE() - INTERVAL 6 DAY"; + break; + case 'month': + $where_date = "AND YEAR(created_at)=YEAR(NOW()) AND MONTH(created_at)=MONTH(NOW())"; + break; + case 'year': + $where_date = "AND YEAR(created_at)=YEAR(NOW())"; + break; + default: // day + $where_date = "AND DATE(created_at) = CURDATE()"; + break; + } + + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT + COALESCE(SUM(fare_total_amount),0) AS gross, + COALESCE(SUM(platform_fee_amount),0) AS fee, + COALESCE(SUM(driver_payout_amount),0) AS net, + COUNT(*) AS trips_count, + COALESCE(SUM(fare_distance_m),0) AS distance_m + FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status = 'finished' $where_date", + $uid + ) ); + + // Last 7 days + $last7 = $wpdb->get_results( $wpdb->prepare( + "SELECT DATE(created_at) AS the_date, COALESCE(SUM(driver_payout_amount),0) AS net + FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status = 'finished' + AND DATE(created_at) >= CURDATE() - INTERVAL 6 DAY + GROUP BY DATE(created_at) + ORDER BY DATE(created_at) ASC", + $uid + ), ARRAY_A ); + + // Fill missing days + $days_map = []; + for ( $i = 6; $i >= 0; $i-- ) { + $d = date( 'Y-m-d', strtotime( "-$i days" ) ); + $days_map[ $d ] = 0; + } + foreach ( $last7 as $r ) { + $days_map[ $r['the_date'] ] = (float) $r['net']; + } + $last7_arr = []; + foreach ( $days_map as $d => $v ) { + $last7_arr[] = [ 'date' => $d, 'net' => $v ]; + } + + return rest_ensure_response( [ + 'gross' => (float) $row->gross, + 'fee' => (float) $row->fee, + 'net' => (float) $row->net, + 'trips_count' => (int) $row->trips_count, + 'distance_km' => round( (float) $row->distance_m / 1000, 2 ), + 'last_7_days' => $last7_arr, + ] ); + } + + // GET /driver/metrics + public function rest_metrics( $req ) { + global $wpdb; + $uid = get_current_user_id(); + + // Last 30 days trips assigned to driver + $total_assigned = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status != 'available' + AND created_at >= NOW() - INTERVAL 30 DAY", + $uid + ) ); + + $accepted = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status NOT IN ('assigned','canceled') + AND created_at >= NOW() - INTERVAL 30 DAY", + $uid + ) ); + + $finished = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status = 'finished' + AND created_at >= NOW() - INTERVAL 30 DAY", + $uid + ) ); + + $canceled = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status = 'canceled' + AND created_at >= NOW() - INTERVAL 30 DAY", + $uid + ) ); + + $avg_rating = (float) $wpdb->get_var( $wpdb->prepare( + "SELECT AVG(driver_rating) FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND driver_rating > 0 + AND created_at >= NOW() - INTERVAL 30 DAY", + $uid + ) ); + + // Online hours this week from sessions + $online_secs = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COALESCE(SUM(online_seconds),0) FROM {$wpdb->prefix}ab_driver_sessions + WHERE driver_id = %d AND started_at >= CURDATE() - INTERVAL 6 DAY", + $uid + ) ); + + // Zone responses + $zones_responded = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_zone_alert_responses + WHERE driver_id = %d AND response = 'going' + AND responded_at >= NOW() - INTERVAL 30 DAY", + $uid + ) ); + $zones_ignored = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_zone_alert_responses + WHERE driver_id = %d AND response IN ('not_going','ignored') + AND responded_at >= NOW() - INTERVAL 30 DAY", + $uid + ) ); + + $acceptance_rate = $total_assigned > 0 ? round( ( $accepted / $total_assigned ) * 100, 1 ) : 0; + $total_started = $accepted; + $completion_rate = $total_started > 0 ? round( ( $finished / $total_started ) * 100, 1 ) : 0; + + return rest_ensure_response( [ + 'acceptance_rate' => $acceptance_rate, + 'completion_rate' => $completion_rate, + 'avg_rating' => round( $avg_rating, 2 ), + 'online_hours_week'=> round( $online_secs / 3600, 1 ), + 'zones_responded' => $zones_responded, + 'zones_ignored' => $zones_ignored, + ] ); + } + + // GET /driver/history + public function rest_history( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $page = max( 1, intval( $req->get_param( 'page' ) ?: 1 ) ); + $per_page = max( 1, min( 100, intval( $req->get_param( 'per_page' ) ?: 20 ) ) ); + $offset = ( $page - 1 ) * $per_page; + $date_from = sanitize_text_field( $req->get_param( 'date_from' ) ?: '' ); + $date_to = sanitize_text_field( $req->get_param( 'date_to' ) ?: '' ); + + try { + $where = $wpdb->prepare( "WHERE driver_id = %d", $uid ); + if ( $date_from ) $where .= $wpdb->prepare( " AND DATE(created_at) >= %s", $date_from ); + if ( $date_to ) $where .= $wpdb->prepare( " AND DATE(created_at) <= %s", $date_to ); + + $total = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips $where" + ); + if ( $wpdb->last_error ) throw new \Exception( $wpdb->last_error ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, trip_uuid, created_at, passenger_name, + pickup_lat, pickup_lng, dropoff_lat, dropoff_lng, + fare_total_amount, platform_fee_amount, driver_payout_amount, + status, driver_rating, currency + FROM {$wpdb->prefix}ab_trips $where + ORDER BY created_at DESC + LIMIT %d OFFSET %d", + $per_page, $offset + ), + ARRAY_A + ); + if ( $wpdb->last_error ) throw new \Exception( $wpdb->last_error ); + + return rest_ensure_response( [ + 'ok' => true, + 'rows' => $rows ?: [], + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages'=> $total > 0 ? (int) ceil( $total / $per_page ) : 0, + ] ); + } catch ( \Exception $e ) { + return rest_ensure_response( [ + 'ok' => true, + 'rows' => [], + 'total' => 0, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages'=> 0, + ] ); + } + } + + // GET /driver/scheduled + public function rest_scheduled( $req ) { + global $wpdb; + $uid = get_current_user_id(); + + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}ab_trips + WHERE driver_id = %d AND status = 'assigned' AND pickup_time > NOW() + ORDER BY pickup_time ASC", + $uid + ), ARRAY_A ); + + return rest_ensure_response( $rows ?: [] ); + } + + // GET /driver/profile + public function rest_profile( $req ) { + $uid = get_current_user_id(); + $user = get_userdata( $uid ); + + $driver_name = get_user_meta( $uid, 'driver_name', true ); + if ( ! $driver_name ) $driver_name = get_user_meta( $uid, 'full_name', true ); + if ( ! $driver_name ) { + $fn = get_user_meta( $uid, 'first_name', true ); + $ln = get_user_meta( $uid, 'last_name', true ); + $driver_name = trim( "$fn $ln" ); + } + if ( ! $driver_name ) $driver_name = $user->display_name; + + $photo = get_user_meta( $uid, 'photo_url', true ) ?: get_user_meta( $uid, 'driver_photo', true ); + $vehicle_plate = get_user_meta( $uid, 'vehicle_plate', true ) ?: get_user_meta( $uid, 'license_plate', true ); + + $insurance_expiry = get_user_meta( $uid, 'insurance_expiry', true ); + $license_expiry = get_user_meta( $uid, 'license_expiry', true ); + $inspection_expiry = get_user_meta( $uid, 'inspection_expiry', true ); + + $now = time(); + + return rest_ensure_response( [ + 'name' => $driver_name, + 'email' => $user->user_email, + 'phone' => get_user_meta( $uid, 'phone', true ) ?: get_user_meta( $uid, 'billing_phone', true ), + 'vehicle_type' => get_user_meta( $uid, 'vehicle_type', true ), + 'vehicle_plate' => $vehicle_plate, + 'photo_url' => $photo, + 'insurance_expiry' => $insurance_expiry, + 'license_expiry' => $license_expiry, + 'inspection_expiry' => $inspection_expiry, + 'insurance_status' => $this->doc_status( $insurance_expiry ), + 'license_status' => $this->doc_status( $license_expiry ), + 'inspection_status' => $this->doc_status( $inspection_expiry ), + ] ); + } + + private function doc_status( $expiry_date ) { + if ( ! $expiry_date ) return 'unknown'; + $ts = strtotime( $expiry_date ); + $diff = $ts - time(); + if ( $diff < 0 ) return 'expired'; + if ( $diff < 30 * 86400 ) return 'warning'; + return 'ok'; + } + + // POST /driver/change-password + public function rest_change_password( $req ) { + $uid = get_current_user_id(); + $current = $req->get_param('current_password'); + $new_pw = $req->get_param('new_password'); + $confirm = $req->get_param('confirm_password'); + + if ( ! $current || ! $new_pw || ! $confirm ) { + return new WP_Error( 'missing_fields', 'Todos los campos son requeridos.', [ 'status' => 400 ] ); + } + if ( $new_pw !== $confirm ) { + return new WP_Error( 'password_mismatch', 'Las contraseñas no coinciden.', [ 'status' => 400 ] ); + } + if ( strlen( $new_pw ) < 8 ) { + return new WP_Error( 'password_too_short', 'La contraseña debe tener al menos 8 caracteres.', [ 'status' => 400 ] ); + } + + $user = get_userdata( $uid ); + if ( ! wp_check_password( $current, $user->user_pass, $uid ) ) { + return new WP_Error( 'wrong_password', 'Contraseña actual incorrecta.', [ 'status' => 400 ] ); + } + + wp_set_password( $new_pw, $uid ); + return rest_ensure_response( [ 'ok' => true, 'msg' => 'Contraseña actualizada correctamente.' ] ); + } + + // POST /driver/update-profile + public function rest_update_profile( $req ) { + $uid = get_current_user_id(); + $phone = sanitize_text_field( $req->get_param('phone') ?: '' ); + $vehicle = sanitize_text_field( $req->get_param('vehicle_type') ?: '' ); + $plate = sanitize_text_field( $req->get_param('vehicle_plate') ?: '' ); + $photo = esc_url_raw( $req->get_param('photo_url') ?: '' ); + + if ( $phone ) update_user_meta( $uid, 'phone', $phone ); + if ( $vehicle ) update_user_meta( $uid, 'vehicle_type', $vehicle ); + if ( $plate ) { + update_user_meta( $uid, 'vehicle_plate', $plate ); + update_user_meta( $uid, 'license_plate', $plate ); + } + if ( $photo ) update_user_meta( $uid, 'photo_url', $photo ); + + return rest_ensure_response( [ 'ok' => true, 'msg' => 'Perfil actualizado correctamente.' ] ); + } + + // GET /driver/zones + public function rest_zones( $req ) { + global $wpdb; + + $rows = $wpdb->get_results( + "SELECT id, title, description, lat, lng, radius_km, bonus_amount, bonus_currency, + countdown_seconds, type, expires_at + FROM {$wpdb->prefix}ab_zone_alerts + WHERE active = 1 AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC", + ARRAY_A + ); + + return rest_ensure_response( $rows ?: [] ); + } + + // POST /driver/zone-response + public function rest_zone_response( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $body = $req->get_json_params(); + $zone_id = intval( $body['zone_id'] ?? 0 ); + $resp = sanitize_text_field( $body['response'] ?? '' ); + + if ( ! $zone_id || ! in_array( $resp, [ 'going', 'not_going' ], true ) ) { + return new WP_Error( 'bad_request', 'Invalid parameters.', [ 'status' => 400 ] ); + } + + $wpdb->insert( "{$wpdb->prefix}ab_zone_alert_responses", [ + 'zone_id' => $zone_id, + 'driver_id' => $uid, + 'response' => $resp, + 'responded_at' => current_time( 'mysql' ), + ] ); + + return rest_ensure_response( [ 'ok' => true ] ); + } + + // POST /incident/sos + public function rest_sos( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $body = $req->get_json_params(); + + $lat = floatval( $body['lat'] ?? 0 ); + $lng = floatval( $body['lng'] ?? 0 ); + $trip_uuid = sanitize_text_field( $body['trip_uuid'] ?? '' ); + $chunk_index = isset( $body['chunk_index'] ) ? intval( $body['chunk_index'] ) : null; + $audio_data = $body['audio_data'] ?? null; + $incident_id = intval( $body['incident_id'] ?? 0 ); + + if ( ! $incident_id ) { + // First call — create incident + $trip_id = null; + if ( $trip_uuid ) { + $trip_id = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}ab_trips WHERE trip_uuid = %s LIMIT 1", + $trip_uuid + ) ); + } + + $wpdb->insert( "{$wpdb->prefix}ab_incidents", [ + 'driver_id' => $uid, + 'trip_id' => $trip_id ?: null, + 'lat' => $lat, + 'lng' => $lng, + 'status' => 'active', + 'chunks_count'=> 0, + 'incident_at' => current_time( 'mysql' ), + ] ); + $incident_id = $wpdb->insert_id; + + // Send admin email + $admin_email = get_option( 'admin_email' ); + $maps_link = "https://maps.google.com/?q={$lat},{$lng}"; + $user = get_userdata( $uid ); + $subject = '🚨 SOS ALERT — Autobooking Driver'; + $message = "Driver: {$user->display_name} (ID: {$uid})\n"; + $message .= "GPS: {$lat}, {$lng}\n"; + $message .= "Google Maps: {$maps_link}\n"; + if ( $trip_uuid ) $message .= "Trip UUID: {$trip_uuid}\n"; + $message .= "Time: " . current_time( 'mysql' ) . "\n"; + wp_mail( $admin_email, $subject, $message ); + } + + // Save audio chunk if provided + if ( $audio_data && $incident_id ) { + $upload_dir = wp_upload_dir(); + $dir = $upload_dir['basedir'] . '/ab-incidents/' . $incident_id; + if ( ! file_exists( $dir ) ) { + wp_mkdir_p( $dir ); + } + $idx = intval( $chunk_index ) ?: 0; + $filename = $dir . '/chunk_' . $idx . '.webm'; + $decoded = base64_decode( $audio_data ); + if ( $decoded !== false ) { + file_put_contents( $filename, $decoded ); + } + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}ab_incidents SET chunks_count = chunks_count + 1 WHERE id = %d", + $incident_id + ) ); + } + + return rest_ensure_response( [ 'ok' => true, 'incident_id' => $incident_id ] ); + } + + // POST /telemetry/position + public function rest_position( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $body = $req->get_json_params(); + + $lat = floatval( $body['lat'] ?? 0 ); + $lng = floatval( $body['lng'] ?? 0 ); + $accuracy = intval( $body['accuracy'] ?? 0 ); + $trip_id = intval( $body['trip_id'] ?? 0 ); + + // Update driver status + $exists = $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}ab_driver_status WHERE driver_id = %d", $uid + ) ); + $now = current_time( 'mysql' ); + $data = [ + 'last_lat' => $lat, + 'last_lng' => $lng, + 'last_accuracy' => $accuracy, + 'last_seen' => $now, + ]; + if ( $exists ) { + $wpdb->update( "{$wpdb->prefix}ab_driver_status", $data, [ 'driver_id' => $uid ] ); + } else { + $data['driver_id'] = $uid; + $wpdb->insert( "{$wpdb->prefix}ab_driver_status", $data ); + } + + // Insert trip position if trip_id given (table may not exist — catch quietly) + if ( $trip_id ) { + $wpdb->query( $wpdb->prepare( + "INSERT IGNORE INTO {$wpdb->prefix}ab_trip_positions (trip_id, driver_id, lat, lng, accuracy, recorded_at) + VALUES (%d, %d, %s, %s, %d, %s)", + $trip_id, $uid, $lat, $lng, $accuracy, $now + ) ); + } + + return rest_ensure_response( [ 'ok' => true ] ); + } + + // POST /trip/start + public function rest_trip_start( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $body = $req->get_json_params(); + $trip_uuid = sanitize_text_field( $body['trip_uuid'] ?? '' ); + + if ( ! $trip_uuid ) { + return new WP_Error( 'bad_request', 'trip_uuid required.', [ 'status' => 400 ] ); + } + + $affected = $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}ab_trips SET status = 'in_progress', pickup_time = NOW(), updated_at = NOW() + WHERE trip_uuid = %s AND driver_id = %d AND status = 'waiting'", + $trip_uuid, $uid + ) ); + + if ( ! $affected ) { + return new WP_Error( 'not_found', 'Trip not found or invalid state.', [ 'status' => 404 ] ); + } + + return rest_ensure_response( [ 'ok' => true ] ); + } + + // POST /trip/finish + public function rest_trip_finish( $req ) { + global $wpdb; + $uid = get_current_user_id(); + $body = $req->get_json_params(); + + $trip_uuid = sanitize_text_field( $body['trip_uuid'] ?? '' ); + $tolls = floatval( $body['tolls_amount'] ?? 0 ); + $tips = floatval( $body['tips_amount'] ?? 0 ); + $final_amount = isset( $body['final_amount'] ) ? floatval( $body['final_amount'] ) : null; + + if ( ! $trip_uuid ) { + return new WP_Error( 'bad_request', 'trip_uuid required.', [ 'status' => 400 ] ); + } + + // Fetch trip to recalculate payout + $trip = $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}ab_trips WHERE trip_uuid = %s AND driver_id = %d", + $trip_uuid, $uid + ) ); + + if ( ! $trip ) { + return new WP_Error( 'not_found', 'Trip not found.', [ 'status' => 404 ] ); + } + + $payout = $final_amount !== null + ? $final_amount + : ( floatval( $trip->driver_payout_amount ) + $tolls + $tips ); + + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}ab_trips + SET status = 'finished', dropoff_time = NOW(), updated_at = NOW(), + tolls_amount = %f, driver_payout_amount = %f + WHERE trip_uuid = %s AND driver_id = %d", + $tolls, $payout, $trip_uuid, $uid + ) ); + + // Clear current_trip_id in driver status + $wpdb->update( + "{$wpdb->prefix}ab_driver_status", + [ 'current_trip_id' => null ], + [ 'driver_id' => $uid ] + ); + + return rest_ensure_response( [ 'ok' => true ] ); + } + + /* ───────────────────────────────────────────────────────────── + SHORTCODE RENDER + ───────────────────────────────────────────────────────────── */ + public function render( $atts ) { + if ( ! is_user_logged_in() ) { + return '
' + . '

🔒 Acceso Restringido

' + . '

Debes iniciar sesión para acceder al panel de conductor.

' + . 'Iniciar sesión' + . '
'; + } + + $user = wp_get_current_user(); + $is_driver = in_array( 'driver', (array) $user->roles, true ) || in_array( 'administrator', (array) $user->roles, true ); + if ( ! $is_driver ) { + return '
' + . '

🚫 Acceso Denegado

' + . '

Esta página es exclusiva para conductores registrados.

' + . '
'; + } + + $uid = $user->ID; + $render_photo = get_user_meta( $uid, 'photo_url', true ) + ?: get_user_meta( $uid, 'driver_photo', true ) + ?: get_avatar_url( $uid, [ 'size' => 112 ] ) + ?: ''; + + ob_start(); + ?> +
+ + +
+ +
+
+
+ + + + + +
+
+ > +
+
Conductor
+
+
+
+
+ OFFLINE + +
+
+ + + + + +
+ + + + + +
+
+ +
+ + + +
+
GPS: —
+
+ + +
+ + +
+
+ +
+
★ —
+
+

Conéctate para recibir viajes

+
+
+ + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ [lat_min, lat_max, lng_min, lng_max] + Se pueden agregar más países desde el admin sin tocar el código. +─────────────────────────────────────────────────────────────── */ +function ab_get_country_boxes() { + return [ + 'US' => [ + [ 24.5, 49.5, -125.0, -66.9 ], // Contiguous USA + [ 51.2, 71.5, -180.0, -129.9 ], // Alaska + [ 18.9, 22.2, -160.2, -154.8 ], // Hawaii + ], + 'CO' => [[ 1.5, 13.4, -79.0, -66.8 ]], + 'MX' => [[ 14.5, 32.7, -118.4, -86.7 ]], + 'CA' => [[ 41.7, 83.1, -141.0, -52.6 ]], + 'GB' => [[ 49.9, 60.9, -8.2, 1.8 ]], + 'ES' => [[ 35.9, 43.8, -9.3, 4.3 ]], + 'AR' => [[-55.0, -21.8, -73.6, -53.6 ]], + 'BR' => [[-33.7, 5.3, -73.9, -34.8 ]], + 'CL' => [[-55.9, -17.5, -75.6, -66.9 ]], + 'PE' => [[-18.4, -0.0, -81.3, -68.7 ]], + 'EC' => [[ -5.0, 1.5, -81.0, -75.2 ]], + 'VE' => [[ 0.6, 12.2, -73.3, -59.8 ]], + ]; +} + +/* ────────────────────────────────────────────────────────────── + HELPER — detecta el país de unas coordenadas + Retorna el código ISO 2 del país si está en las boxes, + o null si no coincide con ningún país configurado. +─────────────────────────────────────────────────────────────── */ +function ab_get_country_from_coords( $lat, $lng ) { + $lat = (float) $lat; + $lng = (float) $lng; + if ( ! $lat || ! $lng ) return null; + + foreach ( ab_get_country_boxes() as $code => $boxes ) { + foreach ( $boxes as $box ) { + if ( $lat >= $box[0] && $lat <= $box[1] && $lng >= $box[2] && $lng <= $box[3] ) { + return $code; + } + } + } + return null; +} + +/* ────────────────────────────────────────────────────────────── + HELPER — verifica si un país está en la lista de permitidos +─────────────────────────────────────────────────────────────── */ +function ab_is_country_allowed( $country_code ) { + if ( ! $country_code ) return false; + $allowed = get_option( 'ab_allowed_countries', [ 'US' ] ); + if ( is_string( $allowed ) ) $allowed = json_decode( $allowed, true ) ?: [ 'US' ]; + return in_array( strtoupper( $country_code ), array_map( 'strtoupper', (array) $allowed ), true ); +} + +/* ────────────────────────────────────────────────────────────── + IP GEOLOCATION — verifica país por IP como backup del GPS +─────────────────────────────────────────────────────────────── */ +function ab_get_country_from_ip( $ip ) { + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) return null; + $cache_key = 'ab_geo_ip_' . md5( $ip ); + $cached = get_transient( $cache_key ); + if ( $cached !== false ) return $cached ?: null; + $response = wp_remote_get( 'https://ip-api.com/json/' . rawurlencode( $ip ) . '?fields=countryCode,status', ['timeout'=>5] ); + if ( is_wp_error( $response ) ) { set_transient( $cache_key, '', 3600 ); return null; } + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $code = ( ! empty( $body['status'] ) && $body['status'] === 'success' ) ? strtoupper( $body['countryCode'] ?? '' ) : ''; + set_transient( $cache_key, $code, 86400 ); + return $code ?: null; +} + +function ab_get_request_ip() { + if ( function_exists( 'abs_get_ip' ) ) return abs_get_ip(); + $ip = ''; + if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { + $parts = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ); $ip = trim( $parts[0] ); + } + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + } + return sanitize_text_field( $ip ); +} + +/* ────────────────────────────────────────────────────────────── + ENDPOINT PÚBLICO — GET /autobooking/v1/geo/check?lat=&lng= + El JS lo llama antes de cualquier acción crítica. + Retorna si el país está permitido Y cuál es el país detectado. +─────────────────────────────────────────────────────────────── */ +add_action( 'rest_api_init', function () { + + // Check de coordenadas + register_rest_route( 'autobooking/v1', '/geo/check', [ + 'methods' => 'GET', + 'callback' => function ( WP_REST_Request $req ) { + $lat = (float) $req->get_param( 'lat' ); + $lng = (float) $req->get_param( 'lng' ); + $country = ab_get_country_from_coords( $lat, $lng ); + $allowed = ab_is_country_allowed( $country ); + + if ( $allowed && $country ) { + $ip_country = ab_get_country_from_ip( ab_get_request_ip() ); + if ( $ip_country && $ip_country !== $country ) { + return rest_ensure_response(['allowed'=>false,'country'=>$country,'message'=>'Ubicación inconsistente. Verifica tu conexión.']); + } + } + return rest_ensure_response( [ + 'allowed' => $allowed, + 'country' => $country, + 'message' => $allowed ? '' : get_option( + 'ab_geo_blocked_msg', + 'AutoBooking is not available in your current location.' + ), + ] ); + }, + 'permission_callback' => '__return_true', + 'args' => [ + 'lat' => [ 'required' => true, 'type' => 'number' ], + 'lng' => [ 'required' => true, 'type' => 'number' ], + ], + ] ); + + // Leer configuración geo (para el admin dashboard) + register_rest_route( 'autobooking/v1', '/admin/geo-settings', [ + 'methods' => 'GET', + 'callback' => function () { + $allowed = get_option( 'ab_allowed_countries', [ 'US' ] ); + if ( is_string( $allowed ) ) $allowed = json_decode( $allowed, true ) ?: [ 'US' ]; + return rest_ensure_response( [ + 'allowed_countries' => array_values( $allowed ), + 'available_countries' => array_keys( ab_get_country_boxes() ), + 'blocked_message' => get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ), + ] ); + }, + 'permission_callback' => function () { + return current_user_can( 'manage_autobooking' ); + }, + ] ); + + // Guardar configuración geo (desde el admin dashboard) + register_rest_route( 'autobooking/v1', '/admin/geo-settings/save', [ + 'methods' => 'POST', + 'callback' => function ( WP_REST_Request $req ) { + $body = $req->get_json_params(); + $allowed = array_map( 'strtoupper', array_filter( (array) ( $body['allowed_countries'] ?? [] ), 'is_string' ) ); + $msg = sanitize_textarea_field( $body['blocked_message'] ?? '' ); + + if ( empty( $allowed ) ) { + return new WP_Error( 'invalid', 'Debe haber al menos un país permitido.', [ 'status' => 400 ] ); + } + + update_option( 'ab_allowed_countries', array_values( $allowed ) ); + if ( $msg ) update_option( 'ab_geo_blocked_msg', $msg ); + + return rest_ensure_response( [ 'ok' => true, 'allowed_countries' => $allowed ] ); + }, + 'permission_callback' => function () { + return current_user_can( 'manage_autobooking' ); + }, + ] ); + +} ); + +/* ────────────────────────────────────────────────────────────── + INTERCEPTOR REST — bloquea endpoints sensibles fuera de países permitidos +─────────────────────────────────────────────────────────────── */ +add_filter( 'rest_dispatch_request', function ( $result, WP_REST_Request $request, $route, $handler ) { + + if ( $request->get_method() !== 'POST' ) return $result; + + $body = $request->get_json_params() ?: []; + $lat = 0; + $lng = 0; + + /* Conductor se pone online */ + if ( preg_match( '#^/autobooking/v1/driver/status$#', $route ) ) { + if ( ! empty( $body['online'] ) ) { + $lat = floatval( $body['lat'] ?? 0 ); + $lng = floatval( $body['lng'] ?? 0 ); + if ( $lat && $lng ) { + $country = ab_get_country_from_coords( $lat, $lng ); + if ( ! ab_is_country_allowed( $country ) ) { + return new WP_Error( + 'geo_restricted', + get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ), + [ 'status' => 403, 'country' => $country ] + ); + } + } + } + return $result; + } + + /* Pasajero solicita viaje */ + if ( preg_match( '#^/autobooking/v1/passenger/(request|book|ride)$#', $route ) ) { + $lat = floatval( $body['origin_lat'] ?? $body['pickup_lat'] ?? 0 ); + $lng = floatval( $body['origin_lng'] ?? $body['pickup_lng'] ?? 0 ); + if ( $lat && $lng ) { + $country = ab_get_country_from_coords( $lat, $lng ); + if ( ! ab_is_country_allowed( $country ) ) { + return new WP_Error( + 'geo_restricted', + get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ), + [ 'status' => 403, 'country' => $country ] + ); + } + } + return $result; + } + + /* Corporate reserva viaje */ + if ( preg_match( '#^/autobooking/v1/corporate/(book|trip|request)$#', $route ) ) { + $lat = floatval( $body['origin_lat'] ?? 0 ); + $lng = floatval( $body['origin_lng'] ?? 0 ); + if ( $lat && $lng ) { + $country = ab_get_country_from_coords( $lat, $lng ); + if ( ! ab_is_country_allowed( $country ) ) { + return new WP_Error( + 'geo_restricted', + get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ), + [ 'status' => 403, 'country' => $country ] + ); + } + } + return $result; + } + + return $result; + +}, 10, 4 ); diff --git a/autobooking-nav-guard.php b/autobooking-nav-guard.php new file mode 100644 index 0000000..3b8b947 --- /dev/null +++ b/autobooking-nav-guard.php @@ -0,0 +1,146 @@ + URL ────────────────────────────── */ +define('AB_NAV_PAGES', [ + 'driver' => '/driver-dashboard-2/', + 'passenger' => '/customer-dashboard/', + 'corporate' => '/corporate-dashboard/', +]); + +/* ── Session ───────────────────────────────────────────────────────── */ +add_action('init', function () { + if (!session_id() && !headers_sent()) session_start(); +}, 1); + +/* ── Roles permitidos por destino ───────────────────────────────────── */ +function ab_nav_allowed_roles() { + return [ + 'driver' => ['driver', 'driver_pending'], + 'passenger' => ['subscriber', 'customer'], + 'corporate' => ['corporate_admin'], + ]; +} + +/* ── Protección via template_redirect (antes de cualquier output) ──── */ +add_action('template_redirect', function () { + global $post; + if (!$post) return; + + $dest = get_post_meta($post->ID, '_ab_nav_page', true); + if (!$dest) return; + + if (!is_user_logged_in()) { + wp_redirect(wp_login_url(get_permalink())); + exit; + } + + $uid = get_current_user_id(); + $user = wp_get_current_user(); + $roles = (array) $user->roles; + + // Admins y manage_autobooking: acceso siempre sin token + if (in_array('administrator', $roles) || user_can($uid, 'manage_autobooking')) { + return; + } + + if (!session_id()) session_start(); + $token = $_SESSION['ab_nav'][$dest] ?? null; + + // Token válido → pasar + if ($token && $token['user'] === $uid && $token['exp'] >= time()) { + return; + } + + // Sin token pero tiene el rol correcto → auto-token y pasar + $allowed = ab_nav_allowed_roles()[$dest] ?? []; + if (array_intersect($roles, $allowed)) { + $_SESSION['ab_nav'][$dest] = ['user' => $uid, 'exp' => time() + 300]; + return; + } + + // Rol incorrecto para esta página → home + wp_redirect(home_url('/')); + exit; +}); + +/* ── Auto-token en login → acceso directo al dashboard ─────────────── */ +add_action('wp_login', function ($login, $user) { + if (!session_id()) session_start(); + $roles = (array) $user->roles; + $exp = time() + 300; + + $role_map = [ + 'driver' => 'driver', + 'driver_pending' => 'driver', + 'subscriber' => 'passenger', + 'customer' => 'passenger', + 'corporate_admin' => 'corporate', + ]; + foreach ($role_map as $role => $dest) { + if (in_array($role, $roles, true)) { + $_SESSION['ab_nav'][$dest] = ['user' => $user->ID, 'exp' => $exp]; + } + } + if (user_can($user->ID, 'manage_autobooking') || in_array('administrator', $roles, true)) { + foreach (array_keys(AB_NAV_PAGES) as $dest) { + $_SESSION['ab_nav'][$dest] = ['user' => $user->ID, 'exp' => $exp]; + } + } +}, 10, 2); + +/* ── Redirect post-login al dashboard según rol ────────────────────── */ +add_filter('login_redirect', function ($redirect, $request, $user) { + if (is_wp_error($user)) return $redirect; + $roles = (array) $user->roles; + + if (in_array('driver', $roles) || in_array('driver_pending', $roles)) + return home_url(AB_NAV_PAGES['driver']); + if (in_array('corporate_admin', $roles)) + return home_url(AB_NAV_PAGES['corporate']); + if (in_array('subscriber', $roles) || in_array('customer', $roles)) + return home_url(AB_NAV_PAGES['passenger']); + + return $redirect; +}, 10, 3); + +/* ── REST: generar token desde JS ───────────────────────────────────── */ +add_action('rest_api_init', function () { + register_rest_route('ab/v1', '/nav-token', [ + 'methods' => 'POST', + 'callback' => function (WP_REST_Request $req) { + $dest = sanitize_text_field($req->get_param('destination')); + if (!$dest) return new WP_Error('missing', 'destination required', ['status' => 400]); + if (!session_id()) session_start(); + $_SESSION['ab_nav'][$dest] = ['user' => get_current_user_id(), 'exp' => time() + 300]; + return rest_ensure_response(['ok' => true, 'destination' => $dest]); + }, + 'permission_callback' => fn() => is_user_logged_in(), + ]); +}); + +/* ── JS helper global ───────────────────────────────────────────────── */ +add_action('wp_footer', function () { + if (!is_user_logged_in()) return; + echo ''; +}); + +/* ── Limpieza de tokens expirados ───────────────────────────────────── */ +add_action('init', function () { + if (!session_id() || empty($_SESSION['ab_nav'])) return; + $now = time(); + foreach ($_SESSION['ab_nav'] as $k => $d) { + if ($d['exp'] < $now) unset($_SESSION['ab_nav'][$k]); + } +}, 5); diff --git a/autobooking-roles.php b/autobooking-roles.php new file mode 100644 index 0000000..9a57253 --- /dev/null +++ b/autobooking-roles.php @@ -0,0 +1,113 @@ + true, + ABR_CAP_READ_DRIVER_DASH => true, + ]; + if ( ! get_role( ABR_ROLE_DRIVER ) ) { + add_role( ABR_ROLE_DRIVER, 'Driver', $driver_caps ); + } else { + $r = get_role( ABR_ROLE_DRIVER ); + foreach ( $driver_caps as $cap => $grant ) { + if ( ! $r->has_cap( $cap ) ) $r->add_cap( $cap, $grant ); + } + } + + // --- Role: Pending Driver --- + $pending_caps = [ 'read' => true ]; + if ( ! get_role( ABR_ROLE_DRIVER_PENDING ) ) { + add_role( ABR_ROLE_DRIVER_PENDING, 'Pending Driver', $pending_caps ); + } else { + $r = get_role( ABR_ROLE_DRIVER_PENDING ); + foreach ( $pending_caps as $cap => $grant ) { + if ( ! $r->has_cap( $cap ) ) $r->add_cap( $cap, $grant ); + } + } + + // --- Role: Corporate Admin --- + $corp_caps = [ + 'read' => true, + ABR_CAP_READ_CORPORATE_DASH => true, + ]; + if ( ! get_role( ABR_ROLE_CORPORATE ) ) { + add_role( ABR_ROLE_CORPORATE, 'Corporate Admin', $corp_caps ); + } else { + $r = get_role( ABR_ROLE_CORPORATE ); + foreach ( $corp_caps as $cap => $grant ) { + if ( ! $r->has_cap( $cap ) ) $r->add_cap( $cap, $grant ); + } + } + + // --- Administrator: acceso total --- + $admin = get_role( 'administrator' ); + if ( $admin ) { + foreach ( [ ABR_CAP_READ_DRIVER_DASH, ABR_CAP_READ_CUST_DASH, ABR_CAP_READ_CORPORATE_DASH, ABR_CAP_MANAGE_AB ] as $cap ) { + if ( ! $admin->has_cap( $cap ) ) $admin->add_cap( $cap ); + } + } +} + +/* ------------------------------------------------------------------ + Migracion retroactiva: asigna rol corporate_admin a usuarios WP + que ya tienen ab_company_id en usermeta y no tienen el rol. + Idempotente: correrlo dos veces no duplica ni rompe nada. +------------------------------------------------------------------ */ +function abr_migrate_existing_corporate_users() { + global $wpdb; + + $user_ids = $wpdb->get_col( + "SELECT DISTINCT um.user_id + FROM {$wpdb->usermeta} um + INNER JOIN {$wpdb->prefix}ab_companies c ON c.id = CAST(um.meta_value AS UNSIGNED) + WHERE um.meta_key = 'ab_company_id' + AND um.meta_value != '' + AND um.meta_value != '0' + AND c.active = 1" + ); + + if ( empty( $user_ids ) ) return; + + foreach ( $user_ids as $uid ) { + $user = get_userdata( (int) $uid ); + if ( ! $user ) continue; + if ( ! in_array( ABR_ROLE_CORPORATE, (array) $user->roles, true ) ) { + $user->add_role( ABR_ROLE_CORPORATE ); + } + } +} + +function abr_activation() { + abr_register_roles_caps(); + abr_migrate_existing_corporate_users(); +} +register_activation_hook( __FILE__, 'abr_activation' ); + +add_action( 'init', 'abr_register_roles_caps' ); diff --git a/autobooking-security.php b/autobooking-security.php new file mode 100644 index 0000000..b761faa --- /dev/null +++ b/autobooking-security.php @@ -0,0 +1,320 @@ +get_charset_collate(); + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_rate_limits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL DEFAULT '', + endpoint VARCHAR(80) NOT NULL DEFAULT '', + hits INT UNSIGNED NOT NULL DEFAULT 1, + window_start DATETIME NOT NULL, + PRIMARY KEY (id), KEY ip_ep_win (ip, endpoint, window_start) + ) $charset;" ); + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_blocked_ips ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL DEFAULT '', + reason VARCHAR(120) NOT NULL DEFAULT '', + blocked_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + PRIMARY KEY (id), UNIQUE KEY ip (ip), KEY ip_exp (ip, expires_at) + ) $charset;" ); + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_driver_documents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + driver_id BIGINT UNSIGNED NOT NULL, + doc_type ENUM('license','insurance','vehicle_photo') NOT NULL, + file_url VARCHAR(512) NOT NULL DEFAULT '', + expiry_date DATE NULL, + status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending', + reviewed_by BIGINT UNSIGNED NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), KEY driver_doc (driver_id, doc_type), KEY status (status) + ) $charset;" ); +} + +/* ── HELPERS ──────────────────────────────────────────────── */ +function abs_get_ip() { + $ip = ''; + if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { + $parts = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ); + $ip = trim( $parts[0] ); + } + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + } + return sanitize_text_field( $ip ); +} + +function abs_is_whitelisted( $ip ) { + $raw = get_option( 'ab_sec_whitelist', '' ); + if ( ! $raw ) return false; + return in_array( $ip, array_map( 'trim', explode( "\n", $raw ) ), true ); +} + +function abs_is_blocked( $ip ) { + global $wpdb; + return (bool) $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}ab_blocked_ips WHERE ip = %s AND expires_at > %s", + $ip, current_time( 'mysql' ) + ) ); +} + +function abs_block_ip( $ip, $reason, $hours ) { + global $wpdb; + $wpdb->query( $wpdb->prepare( + "INSERT INTO {$wpdb->prefix}ab_blocked_ips (ip, reason, blocked_at, expires_at) VALUES (%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE reason=VALUES(reason), blocked_at=VALUES(blocked_at), expires_at=VALUES(expires_at)", + $ip, $reason, current_time('mysql'), date('Y-m-d H:i:s', time() + $hours * 3600) + ) ); +} + +function abs_check_rate( $ip, $endpoint, $limit, $window_sec, $block_hours = 0 ) { + if ( ! $ip || abs_is_whitelisted( $ip ) ) return true; + if ( abs_is_blocked( $ip ) ) return false; + global $wpdb; $table = $wpdb->prefix . 'ab_rate_limits'; + $window = date( 'Y-m-d H:i:s', time() - $window_sec ); + $hits = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT SUM(hits) FROM $table WHERE ip=%s AND endpoint=%s AND window_start>=%s", + $ip, $endpoint, $window + ) ); + if ( $hits >= $limit ) { + if ( $block_hours > 0 ) abs_block_ip( $ip, "Rate limit: $endpoint", $block_hours ); + return false; + } + $minute = date( 'Y-m-d H:i:00' ); + $exists = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM $table WHERE ip=%s AND endpoint=%s AND window_start=%s", + $ip, $endpoint, $minute + ) ); + if ( $exists ) { + $wpdb->query( $wpdb->prepare( + "UPDATE $table SET hits=hits+1 WHERE ip=%s AND endpoint=%s AND window_start=%s", + $ip, $endpoint, $minute + ) ); + } else { + $wpdb->insert( $table, ['ip'=>$ip,'endpoint'=>$endpoint,'hits'=>1,'window_start'=>$minute] ); + } + return true; +} + +/* ── RATE LIMITING: login ─────────────────────────────────── */ +add_action( 'wp_login_failed', function( $u ) { + abs_check_rate( abs_get_ip(), 'login', 10, 900, 2 ); +} ); +add_action( 'login_init', function() { + if ( abs_is_blocked( abs_get_ip() ) ) + wp_die( 'Tu IP fue bloqueada temporalmente por demasiados intentos fallidos.', 'Bloqueado', ['response'=>429] ); +} ); + +/* ── RATE LIMITING: registro ──────────────────────────────── */ +add_filter( 'registration_errors', function( $errors, $login, $email ) { + if ( ! abs_check_rate( abs_get_ip(), 'register', 5, 3600, 24 ) ) + $errors->add( 'rate_limit', 'Error: Demasiados intentos desde tu IP. Intenta más tarde.' ); + return $errors; +}, 10, 3 ); + +/* ── RATE LIMITING: REST ──────────────────────────────────── */ +add_filter( 'rest_dispatch_request', function( $result, $request, $route, $handler ) { + if ( strpos( $route, '/autobooking/v1/' ) === false ) return $result; + if ( $request->get_method() === 'GET' ) return $result; + if ( ! abs_check_rate( abs_get_ip(), 'rest_post', 120, 60, 0 ) ) + return new WP_Error( 'rate_limited', 'Demasiadas solicitudes.', ['status'=>429] ); + return $result; +}, 10, 4 ); + +/* ── LIMPIEZA DIARIA ──────────────────────────────────────── */ +add_action( 'abs_daily_cleanup', function() { + global $wpdb; + $wpdb->query("DELETE FROM {$wpdb->prefix}ab_rate_limits WHERE window_start < DATE_SUB(NOW(), INTERVAL 2 DAY)"); + $wpdb->query("DELETE FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at < NOW()"); +} ); +if ( ! wp_next_scheduled('abs_daily_cleanup') ) wp_schedule_event( time(), 'daily', 'abs_daily_cleanup' ); + +/* ── HCAPTCHA ─────────────────────────────────────────────── */ +add_action( 'register_form', function() { + $key = get_option('ab_hcaptcha_site_key',''); + if ( ! $key ) return; + echo '
'; + echo ''; +} ); +add_filter( 'registration_errors', function( $errors, $login, $email ) { + $secret = get_option('ab_hcaptcha_secret',''); + if ( ! $secret ) return $errors; + $token = sanitize_text_field( $_POST['h-captcha-response'] ?? '' ); + if ( ! $token ) { $errors->add('captcha_missing','Error: Completa el captcha.'); return $errors; } + $res = wp_remote_post('https://api.hcaptcha.com/siteverify', [ + 'body' => ['secret'=>$secret,'response'=>$token,'remoteip'=>abs_get_ip()], + 'timeout' => 10, + ]); + if ( ! is_wp_error($res) ) { + $body = json_decode(wp_remote_retrieve_body($res), true); + if ( empty($body['success']) ) $errors->add('captcha_invalid','Error: Captcha inválido.'); + } + return $errors; +}, 20, 3 ); + +/* ── EMAIL CONFIRMATION ───────────────────────────────────── */ +add_action( 'user_register', function( $uid ) { + $user = get_userdata($uid); + if ( ! $user || ! in_array('driver_pending',(array)$user->roles,true) ) return; + $token = bin2hex(random_bytes(32)); + update_user_meta($uid,'ab_email_token',$token); + update_user_meta($uid,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400)); + update_user_meta($uid,'ab_email_confirmed',0); + $url = add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email')); + wp_mail($user->user_email,'Confirma tu email — AutoBooking', + "Hola {$user->display_name},\n\nConfirma tu email (válido 24h):\n\n$url\n\nAutoBooking"); +} ); + +add_action( 'template_redirect', function() { + if ( ! is_page('driver-dashboard') && ! is_page('pending-driver') ) return; + if ( ! is_user_logged_in() ) return; + $user = wp_get_current_user(); $roles = (array)$user->roles; + if ( ! in_array('driver_pending',$roles,true) && ! in_array('driver',$roles,true) ) return; + if ( (int)get_user_meta($user->ID,'ab_email_confirmed',true) ) return; + $resend = esc_url(get_rest_url(null,'autobooking/v1/resend-confirmation')); + wp_die( + '
' + .'

Confirma tu email

' + .'

Revisa tu bandeja de entrada y haz clic en el enlace de confirmación.

' + .'
'.wp_nonce_field('wp_rest','_wpnonce',true,false) + .'' + .'
', + 'Confirma tu email', ['response'=>403] + ); +} ); + +/* ── REST ENDPOINTS ───────────────────────────────────────── */ +add_action( 'rest_api_init', function() { + + register_rest_route('autobooking/v1','/confirm-email',[ + 'methods'=>'GET','permission_callback'=>'__return_true', + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; + $token = sanitize_text_field($req->get_param('token')); + if(!$token){wp_redirect(home_url('/?ab_confirm=invalid'));exit;} + $uid = $wpdb->get_var($wpdb->prepare( + "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_email_token' AND meta_value=%s LIMIT 1",$token)); + if(!$uid){wp_redirect(home_url('/?ab_confirm=invalid'));exit;} + if(strtotime(get_user_meta((int)$uid,'ab_email_token_expires',true))'POST','permission_callback'=>'is_user_logged_in', + 'callback'=>function(){ + $user=wp_get_current_user(); $token=bin2hex(random_bytes(32)); + update_user_meta($user->ID,'ab_email_token',$token); + update_user_meta($user->ID,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400)); + $url=add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email')); + wp_mail($user->user_email,'Confirma tu email — AutoBooking',"Nuevo enlace (válido 24h):\n\n$url"); + wp_redirect(home_url('/pending-driver/?ab_confirm=resent'));exit; + }, + ]); + + register_rest_route('autobooking/v1','/driver/upload-doc',[ + 'methods'=>'POST','permission_callback'=>'is_user_logged_in', + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $user=wp_get_current_user(); + $doc_type=sanitize_text_field($req->get_param('doc_type')); + if(!in_array($doc_type,['license','insurance','vehicle_photo'],true)) + return new WP_Error('invalid_type','Tipo inválido.',['status'=>400]); + if(empty($_FILES['file'])) return new WP_Error('no_file','Sin archivo.',['status'=>400]); + $file=$_FILES['file']; + if(!in_array($file['type'],['image/jpeg','image/png','application/pdf'],true)) + return new WP_Error('invalid_mime','Solo JPG, PNG o PDF.',['status'=>400]); + if($file['size']>5*1024*1024) return new WP_Error('file_too_large','Máx 5 MB.',['status'=>400]); + require_once ABSPATH.'wp-admin/includes/file.php'; + $up=wp_upload_dir(); $dir=$up['basedir'].'/ab-driver-docs/'.$user->ID; + wp_mkdir_p($dir); + $ext=pathinfo($file['name'],PATHINFO_EXTENSION); + $fname=$doc_type.'_'.time().'.'.$ext; + if(!move_uploaded_file($file['tmp_name'],$dir.'/'.$fname)) + return new WP_Error('upload_failed','Error al guardar.',['status'=>500]); + $url=$up['baseurl'].'/ab-driver-docs/'.$user->ID.'/'.$fname; + $data=['driver_id'=>$user->ID,'doc_type'=>$doc_type,'file_url'=>$url,'status'=>'pending','created_at'=>current_time('mysql')]; + $exists=$wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d AND doc_type=%s",$user->ID,$doc_type)); + if($exists) $wpdb->update("{$wpdb->prefix}ab_driver_documents",$data,['driver_id'=>$user->ID,'doc_type'=>$doc_type]); + else $wpdb->insert("{$wpdb->prefix}ab_driver_documents",$data); + return rest_ensure_response(['ok'=>true,'file_url'=>$url]); + }, + ]); + + register_rest_route('autobooking/v1','/driver/my-docs',[ + 'methods'=>'GET','permission_callback'=>'is_user_logged_in', + 'callback'=>function(){ + global $wpdb; $uid=get_current_user_id(); + return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare( + "SELECT doc_type,file_url,status,expiry_date FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$uid),ARRAY_A)?:[]]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/driver/(?P\d+)/docs',[ + 'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $id=absint($req['id']); + return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$id),ARRAY_A)?:[]]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/doc/(?P\d+)/review',[ + 'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $id=absint($req['id']); + $status=in_array($req->get_param('status'),['approved','rejected'],true)?$req->get_param('status'):'pending'; + $wpdb->update("{$wpdb->prefix}ab_driver_documents", + ['status'=>$status,'reviewed_by'=>get_current_user_id(),'reviewed_at'=>current_time('mysql')],['id'=>$id]); + return rest_ensure_response(['ok'=>true]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/blocked-ips',[ + 'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(){ + global $wpdb; + return rest_ensure_response([ + 'blocked'=>$wpdb->get_results("SELECT id,ip,reason,blocked_at,expires_at FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at>NOW() ORDER BY blocked_at DESC LIMIT 100",ARRAY_A)?:[], + 'blocked_24h'=>(int)$wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}ab_blocked_ips WHERE blocked_at>=DATE_SUB(NOW(),INTERVAL 24 HOUR)"), + ]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/blocked-ips/(?P\d+)/unblock',[ + 'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $wpdb->delete("{$wpdb->prefix}ab_blocked_ips",['id'=>absint($req['id'])]); + return rest_ensure_response(['ok'=>true]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/security-settings/save',[ + 'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + $wl = $req->get_param('whitelist'); + $sk = $req->get_param('hcaptcha_site_key'); + $sec = $req->get_param('hcaptcha_secret'); + if($wl !== null) update_option('ab_sec_whitelist', sanitize_textarea_field($wl)); + if($sk !== null) update_option('ab_hcaptcha_site_key', sanitize_text_field($sk)); + if($sec !== null) update_option('ab_hcaptcha_secret', sanitize_text_field($sec)); + return rest_ensure_response(['ok'=>true,'saved'=>array_filter(['whitelist'=>$wl,'sk'=>$sk,'sec'=>$sec])]); + }, + ]); + +} ); diff --git a/autobooking-simulator.php b/autobooking-simulator.php new file mode 100644 index 0000000..e1cbcaf --- /dev/null +++ b/autobooking-simulator.php @@ -0,0 +1,678 @@ + [ + 'name' => 'Hialeah → Brickell (S por NW 27th Ave)', + 'from' => 'Hialeah Market', + 'to' => 'Brickell City Centre', + 'wps' => [ + [25.8576, -80.2781], [25.8450, -80.2700], [25.8320, -80.2620], + [25.8190, -80.2540], [25.8060, -80.2460], [25.7930, -80.2380], + [25.7800, -80.2300], [25.7680, -80.2220], [25.7617, -80.1918], + ], + ], + 'B' => [ + 'name' => 'MIA Airport → Miami Beach (E por NW 36th St)', + 'from' => 'Miami International Airport', + 'to' => 'South Beach', + 'wps' => [ + [25.7959, -80.2870], [25.7959, -80.2650], [25.7940, -80.2430], + [25.7880, -80.2210], [25.7820, -80.1990], [25.7760, -80.1770], + [25.7700, -80.1550], [25.7640, -80.1330], [25.7617, -80.1320], + ], + ], + 'C' => [ + 'name' => 'Kendall → Hialeah (N por FL Turnpike) — DESVÍO POSIBLE', + 'from' => 'Kendall Drive', + 'to' => 'Hialeah', + 'wps' => [ + [25.7090, -80.3560], [25.7230, -80.3420], [25.7370, -80.3280], + [25.7510, -80.3140], [25.7650, -80.3000], [25.7790, -80.2860], + [25.7930, -80.2720], [25.8070, -80.2580], [25.8210, -80.2440], + ], + 'deviation_start' => 3, + 'deviation_path' => [ + [25.7520, -80.2900], [25.7540, -80.2650], + [25.7560, -80.2400], [25.7580, -80.2150], + ], + ], + 'D' => [ + 'name' => 'Aventura → Downtown Miami (S por Biscayne Blvd)', + 'from' => 'Aventura Mall', + 'to' => 'Downtown Miami', + 'wps' => [ + [25.9565, -80.1425], [25.9380, -80.1410], [25.9190, -80.1395], + [25.9000, -80.1380], [25.8810, -80.1365], [25.8620, -80.1350], + [25.8240, -80.1320], [25.7900, -80.1800], [25.7750, -80.1900], + ], + ], + 'E' => [ + 'name' => 'Doral → Coral Gables (S por Palmetto Expy)', + 'from' => 'Doral City Place', + 'to' => 'Coral Gables Miracle Mile', + 'wps' => [ + [25.8196, -80.3556], [25.8100, -80.3400], [25.8000, -80.3250], + [25.7900, -80.3100], [25.7800, -80.2950], [25.7700, -80.2800], + [25.7600, -80.2650], + ], + ], + ]; +} + +/* ══════════════════════════════════════════════════════════════════════ + * CONFIGURACIÓN DE CONDUCTORES (12) + * ['login', 'nombre', ruta, step_inicial, empresa_corp|null] + * route FREE = disponible | route OFF = offline + * ══════════════════════════════════════════════════════════════════════ */ +function ab_sim_driver_config() { + return [ + ['abtest_drv_a1', 'Andrés Pérez', 'A', 0, null], + ['abtest_drv_a2', 'Beatriz Torres', 'A', 3, null], + ['abtest_drv_b1', 'Carlos Méndez', 'B', 0, null], + ['abtest_drv_b2', 'Diana López', 'B', 5, 'TechCorp'], + ['abtest_drv_c1', 'Eduardo Ruiz', 'C', 0, null], + ['abtest_drv_d1', 'Felipe Vargas', 'D', 0, 'LogiCorp'], + ['abtest_drv_e1', 'Gabriela Suárez', 'E', 0, null], + ['abtest_drv_f1', 'Hugo Ramírez', 'FREE', null, null], + ['abtest_drv_f2', 'Isabel Castro', 'FREE', null, null], + ['abtest_drv_f3', 'Julián Morales', 'FREE', null, null], + ['abtest_drv_f4', 'Karen Reyes', 'FREE', null, null], + ['abtest_drv_f5', 'Luis Herrera', 'OFF', null, null], + ]; +} + +function ab_sim_haversine($lat1, $lng1, $lat2, $lng2) { + $R = 6371; + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + $a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2; + return $R * 2 * atan2(sqrt($a), sqrt(1 - $a)); +} + +function ab_sim_free_positions() { + return [ + [4.6700, -74.0550], + [4.6490, -74.0680], + [4.6120, -74.0760], + [4.6320, -74.1100], + ]; +} + +/* ══════════════════════════════════════════════════════════════════════ + * ESTADO EN wp_options + * ══════════════════════════════════════════════════════════════════════ */ +function ab_sim_state() { + return get_option(AB_SIM_OPT, [ + 'running' => false, + 'deviation' => false, + 'drivers' => [], + 'last_tick' => 0, + ]); +} +function ab_sim_save($s) { update_option(AB_SIM_OPT, $s); } + +/* ══════════════════════════════════════════════════════════════════════ + * PÁGINA DE ADMINISTRACIÓN (Tools > AB Simulator) + * ══════════════════════════════════════════════════════════════════════ */ +add_action('admin_menu', function () { + add_management_page('AB Simulator', 'AB Simulator', 'manage_options', 'ab-sim', 'ab_sim_page'); +}); + +function ab_sim_page() { + if (isset($_POST['ab_sim_act']) && check_admin_referer('ab_sim')) { + $act = sanitize_key($_POST['ab_sim_act']); + if ($act === 'start') ab_sim_start(); + if ($act === 'stop') ab_sim_stop(); + if ($act === 'reset') ab_sim_reset(); + if ($act === 'deviate') ab_sim_activate_deviation(); + if ($act === 'tick') ab_sim_tick(); + } + + $s = ab_sim_state(); + $routes = ab_sim_routes(); + $running = $s['running']; + ?> +
+

🚗 AutoBooking Simulator

+ +
+

+ Estado: ▶ CORRIENDO' + : '⏸ DETENIDO'; ?> +  |  Último tick: +  |  Desvío ruta C: ⚠ ACTIVO' + : 'inactivo'; ?> +

+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ + +

Conductores ()

+ + + + + + + + + + + + + + +
IDNombreRutaPasoEstado DBLat / Lng
display_name : '—'); ?>
+ + +

Escenarios de prueba

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EscenarioQué pruebaPasos
Mapa en vivoConductores disponibles y activos en el mapa del Command CenterIniciar → abrir Command Center
Viajes activos7 vehículos moviéndose en tiempo real (cada ~30s)Iniciar → refrescar mapa cada 30s
Desvío de rutaConductor C abandona ruta Kennedy→Suba → alerta route_deviation en Command CenterIniciar → 2 ticks → Activar desvío → ver alertas
Viaje corporativoDiana López (TechCorp) y Felipe Vargas (LogiCorp) con company_id en sus viajesIniciar → ver viajes activos con empresa asignada
Avance manualControl paso a paso para debugging de posiciones exactasUsar "Avanzar un paso" repetidamente
+
+ get_col($wpdb->prepare( + "SELECT id FROM wp_ab_trips WHERE (driver_id=%d OR passenger_id=%d) AND trip_uuid LIKE 'sim\\_%'", + $uid, $uid + )); + if ($trip_ids) { + $in = implode(',', array_map('intval', $trip_ids)); + $wpdb->query("DELETE FROM wp_ab_trip_positions WHERE trip_id IN ($in)"); + $wpdb->query("DELETE FROM wp_ab_incidents WHERE trip_id IN ($in)"); + $wpdb->query("DELETE FROM wp_ab_trips WHERE id IN ($in)"); + } + $wpdb->delete('wp_ab_driver_status', ['driver_id' => $uid]); + if (get_user_meta($uid, AB_SIM_MARKER, true) === '1') { + require_once ABSPATH . 'wp-admin/includes/user.php'; + wp_delete_user($uid); + } + } + + $wpdb->query("DELETE FROM wp_ab_companies WHERE name LIKE '%[SIM]%'"); + delete_option(AB_SIM_OPT); + delete_option('ab_sim_extra_uids'); + delete_option('ab_sim_company_ids'); +} + +/* ══════════════════════════════════════════════════════════════════════ + * SEED — crear usuarios, empresas y viajes iniciales + * ══════════════════════════════════════════════════════════════════════ */ +function ab_sim_seed(&$s) { + global $wpdb; + $routes = ab_sim_routes(); + $drv_cfg = ab_sim_driver_config(); + $free_pos = ab_sim_free_positions(); + $now = current_time('mysql'); + + /* — 2 empresas institucionales — */ + $company_ids = []; + foreach (['TechCorp [SIM]' => 'TechCorp', 'LogiCorp [SIM]' => 'LogiCorp'] as $cname => $ckey) { + $cid = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM wp_ab_companies WHERE name=%s LIMIT 1", $cname + )); + if (!$cid) { + $wpdb->insert('wp_ab_companies', [ + 'name' => $cname, + 'email' => strtolower($ckey) . '@sim.local', + 'status' => 'active', + 'created_at' => $now, + ]); + $cid = $wpdb->insert_id; + } + $company_ids[$ckey] = (int) $cid; + } + update_option('ab_sim_company_ids', $company_ids); + + /* — 20 pasajeros — */ + $pass_names = [ + 'María García', 'José Rodríguez', 'Ana Martínez', 'Luis González', + 'Carmen López', 'Pedro Sánchez', 'Laura Fernández', 'Miguel Torres', + 'Isabel Ramírez', 'Carlos Flores', 'Sara Díaz', 'David Morales', + 'Elena Jiménez', 'Roberto Ruiz', 'Patricia Herrera','Javier Medina', + 'Sofía Delgado', 'Tomás Vargas', 'Valentina Cruz', 'Nicolás Ortiz', + ]; + $pass_ids = []; + foreach ($pass_names as $i => $pname) { + $login = 'abtest_pass_' . ($i + 1); + $existing = get_user_by('login', $login); + if ($existing) { + $pid = $existing->ID; + } else { + $pid = wp_insert_user([ + 'user_login' => $login, + 'user_pass' => wp_generate_password(), + 'display_name' => $pname, + 'role' => 'subscriber', + ]); + if (is_wp_error($pid)) continue; + update_user_meta($pid, AB_SIM_MARKER, '1'); + // Empleados corporativos: índices 16-17 TechCorp, 18-19 LogiCorp + if ($i >= 16 && $i <= 17) update_user_meta($pid, 'ab_company_id', $company_ids['TechCorp']); + if ($i >= 18 && $i <= 19) update_user_meta($pid, 'ab_company_id', $company_ids['LogiCorp']); + } + $pass_ids[] = $pid; + } + update_option('ab_sim_extra_uids', $pass_ids); + + /* — 12 conductores — */ + $s['drivers'] = []; + $free_idx = 0; + + foreach ($drv_cfg as $pi => $cfg) { + [$login, $dname, $route, $step, $corp] = $cfg; + + $existing = get_user_by('login', $login); + if ($existing) { + $uid = $existing->ID; + } else { + $uid = wp_insert_user([ + 'user_login' => $login, + 'user_pass' => wp_generate_password(), + 'display_name' => $dname, + 'role' => 'driver', + ]); + if (is_wp_error($uid)) continue; + update_user_meta($uid, AB_SIM_MARKER, '1'); + } + + // Posición inicial y estado DB + if ($route === 'FREE') { + [$lat, $lng] = $free_pos[$free_idx % count($free_pos)]; + $free_idx++; + $db_status = 'available'; + } elseif ($route === 'OFF') { + $lat = 4.6500; $lng = -74.0600; + $db_status = 'offline'; + } else { + [$lat, $lng] = $routes[$route]['wps'][$step ?? 0]; + $db_status = 'on_trip'; + } + + // Insertar/actualizar wp_ab_driver_status + $exists = $wpdb->get_var($wpdb->prepare( + "SELECT driver_id FROM wp_ab_driver_status WHERE driver_id=%d", $uid + )); + if ($exists) { + $wpdb->update('wp_ab_driver_status', + ['last_lat' => $lat, 'last_lng' => $lng, 'online' => ($db_status === 'offline') ? 0 : 1, 'last_seen' => $now], + ['driver_id' => $uid] + ); + } else { + $wpdb->insert('wp_ab_driver_status', [ + 'driver_id' => $uid, + 'last_lat' => $lat, + 'last_lng' => $lng, + 'online' => ($db_status === 'offline') ? 0 : 1, + 'last_seen' => $now, + ]); + } + + // Crear viaje activo para conductores en ruta + $trip_id = null; + if ($route !== 'FREE' && $route !== 'OFF') { + $pass_id = $pass_ids[$pi] ?? $pass_ids[0]; + $wps = $routes[$route]['wps']; + $last_wp = $wps[count($wps) - 1]; + $company_id = ($corp && isset($company_ids[$corp])) ? $company_ids[$corp] : null; + + $trip_id = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM wp_ab_trips WHERE driver_id=%d AND notes LIKE '%[SIM]%' LIMIT 1", $uid + )); + if (!$trip_id) { + $wps = $routes[$route]['wps']; + $last = $wps[count($wps) - 1]; + $drv_user = get_userdata($uid); + $pax_user = get_userdata($pass_id); + $wpdb->insert('wp_ab_trips', [ + 'trip_uuid' => uniqid('sim_', true), + 'driver_id' => $uid, + 'passenger_id' => $pass_id, + 'driver_name' => $drv_user ? $drv_user->display_name : 'Conductor SIM', + 'passenger_name' => $pax_user ? $pax_user->display_name : 'Pasajero SIM', + 'status' => 'in_progress', + 'pickup_lat' => $wps[0][0], + 'pickup_lng' => $wps[0][1], + 'dropoff_lat' => $last[0], + 'dropoff_lng' => $last[1], + 'pickup_time' => $now, + 'created_at' => $now, + ]); + $trip_id = $wpdb->insert_id; + } + if ($trip_id) { + $wpdb->insert('wp_ab_trip_positions', [ + 'trip_id' => $trip_id, + 'lat' => $lat, + 'lng' => $lng, + 'ts' => $now, + ]); + } + } + + $s['drivers'][] = [ + 'user_id' => $uid, + 'route' => $route, + 'step' => $step ?? 0, + 'trip_id' => $trip_id, + 'db_status' => $db_status, + 'lat' => $lat, + 'lng' => $lng, + 'deviated' => false, + ]; + } +} + +/* ══════════════════════════════════════════════════════════════════════ + * TICK — avanzar todos los vehículos un paso + * ══════════════════════════════════════════════════════════════════════ */ +add_action(AB_SIM_CRON, 'ab_sim_tick'); + +function ab_sim_tick() { + global $wpdb; + $s = ab_sim_state(); + if (!$s['running']) return; + + $routes = ab_sim_routes(); + $now = current_time('mysql'); + + foreach ($s['drivers'] as &$d) { + $route = $d['route']; + + if ($route === 'FREE') { + // Micromovimiento para conductores disponibles + $d['lat'] += (mt_rand(-4, 4) * 0.0001); + $d['lng'] += (mt_rand(-4, 4) * 0.0001); + $wpdb->update('wp_ab_driver_status', + ['lat' => $d['lat'], 'lng' => $d['lng'], 'updated_at' => $now], + ['user_id' => $d['user_id']] + ); + continue; + } + + if ($route === 'OFF') continue; + + $wps = $routes[$route]['wps']; + $dev_start = $routes[$route]['deviation_start'] ?? PHP_INT_MAX; + $dev_path = $routes[$route]['deviation_path'] ?? []; + + $is_deviating = $s['deviation'] && $route === 'C' && $d['step'] >= $dev_start; + + if ($is_deviating) { + $di = $d['step'] - $dev_start; + + if (isset($dev_path[$di])) { + [$d['lat'], $d['lng']] = $dev_path[$di]; + + // Primera vez que desvía: registrar separación pasajero-conductor + if (!$d['deviated'] && $d['trip_id']) { + $sep = $wps[$dev_start - 1] ?? $wps[0]; // último punto en ruta normal + $trip_row = $wpdb->get_row($wpdb->prepare( + "SELECT dropoff_lat, dropoff_lng, passenger_name FROM wp_ab_trips WHERE id = %d", $d['trip_id'] + )); + $dist_dest = ($trip_row && $trip_row->dropoff_lat) + ? ab_sim_haversine($sep[0], $sep[1], $trip_row->dropoff_lat, $trip_row->dropoff_lng) + : 0; + $wpdb->insert('wp_ab_incidents', [ + 'trip_id' => $d['trip_id'], + 'user_id' => $d['user_id'], + 'type' => 'separacion_sospechosa', + 'description' => sprintf( + '[SIM] Conductor abandonó ruta. Pasajero %s último punto conocido: %.5f, %.5f (a %.1f km del destino contratado). Conductor ahora en: %.5f, %.5f.', + $trip_row->passenger_name ?? 'desconocido', + $sep[0], $sep[1], $dist_dest, + $d['lat'], $d['lng'] + ), + 'lat' => $d['lat'], + 'lng' => $d['lng'], + 'status' => 'open', + 'created_at' => $now, + ]); + $d['deviated'] = true; + } + $d['step']++; + + } else { + // Agotó la ruta de desvío — cerrar viaje con alerta de separación fuera del destino + if ($d['trip_id']) { + $trip_row = $wpdb->get_row($wpdb->prepare( + "SELECT dropoff_lat, dropoff_lng, passenger_name FROM wp_ab_trips WHERE id = %d", $d['trip_id'] + )); + $sep = $wps[$dev_start - 1] ?? $wps[0]; + if ($trip_row && $trip_row->dropoff_lat) { + $dist = ab_sim_haversine($sep[0], $sep[1], $trip_row->dropoff_lat, $trip_row->dropoff_lng); + $wpdb->insert('wp_ab_incidents', [ + 'trip_id' => $d['trip_id'], + 'user_id' => $d['user_id'], + 'type' => 'separacion_destino_incorrecto', + 'description' => sprintf( + '[SIM] Viaje cerrado fuera del destino. Pasajero %s separado a %.1f km del destino contratado (%.5f, %.5f). Punto de separación: %.5f, %.5f.', + $trip_row->passenger_name ?? 'desconocido', + $dist, + $trip_row->dropoff_lat, $trip_row->dropoff_lng, + $sep[0], $sep[1] + ), + 'lat' => $sep[0], + 'lng' => $sep[1], + 'status' => 'open', + 'created_at' => $now, + ]); + } + $wpdb->update('wp_ab_trips', + ['status' => 'completed', 'completed_at' => $now], + ['id' => $d['trip_id']] + ); + $d['trip_id'] = null; + } + $d['step'] = 0; + $d['deviated'] = false; + $d['db_status'] = 'available'; + [$d['lat'], $d['lng']] = $wps[0]; + $wpdb->update('wp_ab_driver_status', + ['last_lat' => $d['lat'], 'last_lng' => $d['lng'], 'online' => 1, 'last_seen' => $now], + ['driver_id' => $d['user_id']] + ); + continue; + } + + } else { + $next = $d['step'] + 1; + if ($next >= count($wps)) { + // Llegó al destino — verificar si coincide con el punto contratado + if ($d['trip_id']) { + $trip_row = $wpdb->get_row($wpdb->prepare( + "SELECT dropoff_lat, dropoff_lng, passenger_name FROM wp_ab_trips WHERE id = %d", $d['trip_id'] + )); + if ($trip_row && $trip_row->dropoff_lat) { + $dist = ab_sim_haversine($d['lat'], $d['lng'], $trip_row->dropoff_lat, $trip_row->dropoff_lng); + if ($dist > 0.5) { + $wpdb->insert('wp_ab_incidents', [ + 'trip_id' => $d['trip_id'], + 'user_id' => $d['user_id'], + 'type' => 'separacion_fuera_destino', + 'description' => sprintf( + '[SIM] Viaje completado a %.1f km del destino contratado. Pasajero %s separado en: %.5f, %.5f. Destino original: %.5f, %.5f.', + $dist, + $trip_row->passenger_name ?? 'desconocido', + $d['lat'], $d['lng'], + $trip_row->dropoff_lat, $trip_row->dropoff_lng + ), + 'lat' => $d['lat'], + 'lng' => $d['lng'], + 'status' => 'open', + 'created_at' => $now, + ]); + } + } + $wpdb->update('wp_ab_trips', + ['status' => 'completed', 'completed_at' => $now], + ['id' => $d['trip_id']] + ); + $d['trip_id'] = null; + } + $d['step'] = 0; + $d['deviated'] = false; + $d['db_status'] = 'available'; + [$d['lat'], $d['lng']] = $wps[0]; + $wpdb->update('wp_ab_driver_status', + ['last_lat' => $d['lat'], 'last_lng' => $d['lng'], 'online' => 1, 'last_seen' => $now], + ['driver_id' => $d['user_id']] + ); + continue; + } + $d['step'] = $next; + [$d['lat'], $d['lng']] = $wps[$next]; + } + + $wpdb->update('wp_ab_driver_status', + ['last_lat' => $d['lat'], 'last_lng' => $d['lng'], 'last_seen' => $now], + ['driver_id' => $d['user_id']] + ); + + if ($d['trip_id']) { + $wpdb->insert('wp_ab_trip_positions', [ + 'trip_id' => $d['trip_id'], + 'lat' => $d['lat'], + 'lng' => $d['lng'], + 'ts' => $now, + ]); + } + } + unset($d); + + $s['last_tick'] = time(); + ab_sim_save($s); +} + +/* ══════════════════════════════════════════════════════════════════════ + * CRON — intervalo de 30 segundos + * ══════════════════════════════════════════════════════════════════════ */ +add_filter('cron_schedules', function ($sc) { + $sc['ab_sim_30s'] = ['interval' => 30, 'display' => 'Cada 30s (AutoBooking Sim)']; + return $sc; +}); + +register_deactivation_hook(__FILE__, function () { + wp_clear_scheduled_hook(AB_SIM_CRON); +}); diff --git a/command-center.css b/command-center.css new file mode 100644 index 0000000..6dcc6df --- /dev/null +++ b/command-center.css @@ -0,0 +1,451 @@ +/* ── Command Center — AutoBooking dark theme ─────────────────── */ + +* { box-sizing: border-box; margin: 0; padding: 0; } + +#cc-app { + background: #0b0b0b; + color: #e0e0e0; + font-family: 'Inter', 'Segoe UI', sans-serif; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Header ──────────────────────────────────────────────────── */ + +#cc-header { + background: rgba(17,17,17,0.97); + border-bottom: 1px solid rgba(255,111,0,0.35); + padding: 10px 18px; + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; + z-index: 50; +} + +.cc-brand { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 800; + letter-spacing: 2.5px; + color: #FF6F00; + white-space: nowrap; +} + +.cc-kpis { + display: flex; + gap: 10px; + flex: 1; + justify-content: center; +} + +.cc-kpi { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + padding: 5px 14px; + text-align: center; + min-width: 78px; +} + +.cc-kpi span { + display: block; + font-size: 20px; + font-weight: 700; + color: #FF6F00; + line-height: 1.2; +} + +.cc-kpi small { + font-size: 9px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.cc-kpi-sos { border-color: rgba(255,68,68,0.3); } +.cc-kpi-sos span { color: #ff4444; } +.cc-kpi-sos.sos-active { animation: sosPulse 0.8s ease-in-out infinite; border-color: rgba(255,68,68,0.7); } + +@keyframes sosPulse { + 0%, 100% { background: rgba(255,68,68,0.08); } + 50% { background: rgba(255,68,68,0.25); } +} + +#cc-theme-switcher { + display: flex; + gap: 4px; + align-items: center; +} + +.cc-theme-btn { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 5px; + color: #888; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.5px; + padding: 3px 8px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.cc-theme-btn:hover { + background: rgba(255,255,255,0.1); + color: #ccc; +} + +.cc-theme-btn.active { + background: rgba(255,111,0,0.18); + border-color: rgba(255,111,0,0.5); + color: #FF6F00; +} + +#cc-clock { + font-size: 15px; + font-family: 'Courier New', monospace; + color: #888; + white-space: nowrap; +} + +/* ── Body layout ─────────────────────────────────────────────── */ + +#cc-body { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; +} + +/* ── Map ─────────────────────────────────────────────────────── */ + +#cc-map-wrap { + flex: 2; + position: relative; + min-width: 0; + min-height: 500px; +} + +#cc-map { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-height: 500px; +} + +#cc-legend { + position: absolute; + bottom: 14px; + left: 14px; + background: rgba(11,11,11,0.88); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + padding: 7px 12px; + display: flex; + gap: 14px; + font-size: 11px; + color: #bbb; + z-index: 5; + backdrop-filter: blur(6px); +} + +.leg-dot { + display: inline-block; + width: 9px; height: 9px; + border-radius: 50%; + margin-right: 5px; + vertical-align: middle; +} + +.leg-trip { background: #FF6F00; } +.leg-free { background: #00cc66; } +.leg-sos { background: #ff4444; } + +/* ── Sidebar ─────────────────────────────────────────────────── */ + +#cc-sidebar { + width: 330px; + flex-shrink: 0; + background: rgba(11,11,11,0.97); + border-left: 1px solid rgba(255,111,0,0.18); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.cc-section { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.cc-sec-hdr { + padding: 9px 13px; + font-size: 10px; + font-weight: 800; + letter-spacing: 1.8px; + color: #FF6F00; + background: rgba(255,111,0,0.06); + display: flex; + align-items: center; + gap: 7px; + flex-shrink: 0; + text-transform: uppercase; +} + +.sos-hdr { color: #ff4444; background: rgba(255,68,68,0.07); } + +.cc-badge { + margin-left: auto; + background: #ff4444; + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border-radius: 10px; + min-width: 20px; + text-align: center; + line-height: 1.4; +} + +#sos-list, #trips-list { + overflow-y: auto; + flex: 1; + padding: 6px; + scrollbar-width: thin; + scrollbar-color: rgba(255,111,0,0.3) transparent; +} + +.cc-empty { text-align: center; color: #444; font-size: 12px; padding: 24px 10px; } + +/* ── SOS card ────────────────────────────────────────────────── */ + +.sos-card { + background: rgba(255,68,68,0.08); + border: 1px solid rgba(255,68,68,0.35); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 6px; + transition: border-color 0.2s; +} + +.sos-card:hover { border-color: #ff4444; } + +.sos-card-top { display: flex; justify-content: space-between; margin-bottom: 4px; } +.sos-card-name { font-size: 13px; font-weight: 600; color: #ff7070; } +.sos-card-time { font-size: 11px; color: #666; } +.sos-card-type { font-size: 11px; color: #999; margin-bottom: 8px; } +.sos-card-actions { display: flex; gap: 6px; } + +/* ── Trip card ───────────────────────────────────────────────── */ + +.trip-card { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 6px; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.trip-card:hover, +.trip-card.selected { + border-color: rgba(255,111,0,0.5); + background: rgba(255,111,0,0.05); +} + +.trip-card-driver { font-size: 13px; font-weight: 600; color: #FF6F00; margin-bottom: 2px; } +.trip-card-passenger { font-size: 12px; color: #999; margin-bottom: 3px; } +.trip-card-route { font-size: 11px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trip-card-status { font-size: 10px; color: #00cc66; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; } + +/* ── Google Maps InfoWindow — dark theme override ────────────── */ + +.gm-style-iw-c { + background: rgba(13,13,13,0.95) !important; + border: 1px solid rgba(255,111,0,0.4) !important; + border-radius: 10px !important; + padding: 0 !important; + box-shadow: 0 4px 24px rgba(0,0,0,0.75) !important; +} +.gm-style-iw-d { overflow: hidden !important; } +.gm-style-iw-t::after { background: rgba(13,13,13,0.95) !important; } +.gm-ui-hover-effect, +button[aria-label="Close"] { display: none !important; } + +/* ── Trip panel (sidebar) ────────────────────────────────────── */ + +.cc-trip-panel-section { flex: none !important; } + +.cc-panel-body { + padding: 8px; + overflow-y: auto; + max-height: 340px; + scrollbar-width: thin; + scrollbar-color: rgba(255,111,0,0.3) transparent; +} + +#cc-panel-info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-bottom: 8px; +} + +.cc-info-item label { + display: block; + font-size: 9px; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.cc-info-item span { font-size: 12px; color: #ddd; font-weight: 500; } + +.cc-panel-close { + margin-left: auto; + background: none; border: none; color: #555; + cursor: pointer; font-size: 15px; line-height: 1; + padding: 0 2px; + transition: color 0.2s; +} +.cc-panel-close:hover { color: #fff; } + +#cc-panel-btns { + display: flex; flex-wrap: wrap; gap: 6px; + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.06); +} + +/* ── Pattern analysis ────────────────────────────────────────── */ + +.cc-pattern { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 8px; + padding: 8px 10px; + margin-bottom: 8px; + font-size: 12px; +} +.cc-pattern-hdr { + font-size: 9px; text-transform: uppercase; + letter-spacing: 1.2px; color: #FF6F00; + font-weight: 700; margin-bottom: 7px; +} +.cc-pattern-row { + display: flex; justify-content: space-between; + margin-bottom: 4px; color: #888; font-size: 11px; +} +.cc-pattern-row strong { color: #ddd; } +.cc-p-ok { color: #00cc66; font-size: 11px; font-weight: 600; margin-top: 5px; } +.cc-p-warn { color: #FF6F00; font-size: 11px; font-weight: 600; margin-top: 5px; } +.cc-p-alert { color: #ff4444; font-size: 11px; font-weight: 600; margin-top: 5px; } + +/* ── Buttons ─────────────────────────────────────────────────── */ + +.ccb { + padding: 8px 14px; + border-radius: 8px; border: none; + cursor: pointer; font-size: 12px; font-weight: 600; + transition: opacity 0.15s, transform 0.1s; + white-space: nowrap; font-family: inherit; +} + +.ccb:hover { opacity: 0.8; } +.ccb:active { transform: scale(0.97); } + +.ccb-green { background: rgba(0,204,102,0.15); color: #00cc66; border: 1px solid rgba(0,204,102,0.4); } +.ccb-red { background: rgba(255,68,68,0.15); color: #ff5555; border: 1px solid rgba(255,68,68,0.4); } +.ccb-orange { background: rgba(255,111,0,0.15); color: #FF6F00; border: 1px solid rgba(255,111,0,0.4); } +.ccb-blue { background: rgba(66,153,225,0.15); color: #66b3ff; border: 1px solid rgba(66,153,225,0.4); } +.ccb-dark { background: rgba(255,255,255,0.07); color: #999; border: 1px solid rgba(255,255,255,0.12); } + +.ccbs { padding: 4px 10px; border-radius: 6px; border: none; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; transition: opacity 0.15s; } +.ccbs:hover { opacity: 0.75; } +.ccbs-resolve { background: rgba(0,204,102,0.15); color: #00cc66; border: 1px solid rgba(0,204,102,0.3); } +.ccbs-select { background: rgba(255,111,0,0.12); color: #FF6F00; border: 1px solid rgba(255,111,0,0.3); } + +/* ── Modals ──────────────────────────────────────────────────── */ + +.cc-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 400; align-items: center; justify-content: center; } +.cc-modal.open { display: flex; } + +.cc-modal-inner { + background: #141414; + border: 1px solid rgba(255,111,0,0.3); + border-radius: 12px; + padding: 22px 24px; + width: 480px; max-width: 95vw; + max-height: 90vh; overflow-y: auto; +} + +.cc-modal-inner h3 { color: #FF6F00; font-size: 14px; margin-bottom: 16px; letter-spacing: 0.5px; text-transform: uppercase; } + +.cc-modal-inner select, +.cc-modal-inner textarea { + width: 100%; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.12); + color: #e0e0e0; + border-radius: 8px; + padding: 9px 12px; + font-size: 13px; + margin-bottom: 12px; + resize: vertical; + font-family: inherit; +} + +.cc-modal-inner select { resize: none; } + +.proto-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; } + +.proto-opt { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.09); + color: #ccc; padding: 11px 10px; + border-radius: 8px; cursor: pointer; + font-size: 13px; text-align: left; + transition: all 0.2s; font-family: inherit; +} + +.proto-opt:hover { border-color: rgba(255,111,0,0.4); color: #ddd; } +.proto-opt.selected { background: rgba(255,111,0,0.15); border-color: #FF6F00; color: #FF6F00; } + +.cc-modal-btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; } + +/* ── Toast ───────────────────────────────────────────────────── */ + +.cc-toast { + position: fixed; bottom: 24px; right: 20px; + background: #1c1c1c; border-radius: 8px; + padding: 11px 16px; font-size: 13px; color: #fff; + z-index: 9999; transform: translateX(120%); + transition: transform 0.3s ease; + max-width: 280px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); +} + +.cc-toast.show { transform: translateX(0); } +.cc-toast.t-success { border-left: 3px solid #00cc66; } +.cc-toast.t-warning { border-left: 3px solid #FF6F00; } +.cc-toast.t-error { border-left: 3px solid #ff4444; } +.cc-toast.t-info { border-left: 3px solid #66b3ff; } + +/* ── SOS section flash ───────────────────────────────────────── */ + +.sos-flash { animation: sosFlash 0.5s ease-in-out 6; } + +@keyframes sosFlash { + 0%, 100% { background: transparent; } + 50% { background: rgba(255,68,68,0.15); } +} diff --git a/command-center.js b/command-center.js new file mode 100644 index 0000000..3692dd6 --- /dev/null +++ b/command-center.js @@ -0,0 +1,616 @@ +/* ── Command Center JS ───────────────────────────────────────── */ + +const CC_MAP_THEMES = { + dark: [ + { elementType: 'geometry', stylers: [{ color: '#1a1a2e' }] }, + { elementType: 'labels.text.stroke', stylers: [{ color: '#1a1a2e' }] }, + { elementType: 'labels.text.fill', stylers: [{ color: '#9a9abf' }] }, + { featureType: 'road', elementType: 'geometry', stylers: [{ color: '#2d2d44' }] }, + { featureType: 'road.highway', elementType: 'geometry', stylers: [{ color: '#3d3020' }] }, + { featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0d1321' }] }, + { featureType: 'poi', stylers: [{ visibility: 'off' }] }, + ], + improved: [ + { elementType: 'geometry', stylers: [{ color: '#212121' }] }, + { elementType: 'labels.text.stroke', stylers: [{ color: '#212121' }] }, + { elementType: 'labels.text.fill', stylers: [{ color: '#ffffff' }] }, + { featureType: 'road', elementType: 'geometry', stylers: [{ color: '#3a3a3a' }] }, + { featureType: 'road', elementType: 'labels.text.fill', stylers: [{ color: '#ffffff' }] }, + { featureType: 'road.highway', elementType: 'geometry', stylers: [{ color: '#4a3d20' }] }, + { featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0d2137' }] }, + { featureType: 'landscape', elementType: 'geometry', stylers: [{ color: '#2a2a2a' }] }, + { featureType: 'poi', elementType: 'geometry', stylers: [{ color: '#2a2a2a' }] }, + { featureType: 'poi', elementType: 'labels.text.fill', stylers: [{ color: '#aaaaaa' }] }, + { featureType: 'transit', elementType: 'geometry', stylers: [{ color: '#2f3948' }] }, + ], + normal: [], +}; + +const CC = { + map: null, + markers: {}, + selectedTrip: null, + lastSosCount: 0, + audioCtx: null, + _curTrip: null, + _hoverWin: null, + _geocoder: null, + _geoCache: {}, + + /* ── Map init (called by Google Maps callback) ───────────── */ + + init() { + const savedTheme = localStorage.getItem('cc_map_theme') || 'dark'; + + const mapEl = document.getElementById('cc-map'); + this.map = new google.maps.Map(mapEl, { + zoom: 10, + center: { lat: 25.8100, lng: -80.2780 }, + mapTypeId: 'roadmap', + styles: CC_MAP_THEMES[savedTheme] || CC_MAP_THEMES.dark, + streetViewControl: false, + mapTypeControl: false, + gestureHandling: 'greedy', + }); + google.maps.event.trigger(this.map, 'resize'); + + // Marcar botón activo según preferencia guardada + document.querySelectorAll('.cc-theme-btn').forEach(b => { + b.classList.toggle('active', b.dataset.theme === savedTheme); + }); + document.querySelectorAll('.cc-theme-btn').forEach(b => { + b.addEventListener('click', () => this.setMapTheme(b.dataset.theme)); + }); + + this.startClock(); + this.poll(); + setInterval(() => this.pollMap(), 10000); + setInterval(() => this.pollAlerts(), 5000); + setInterval(() => this.pollStats(), 30000); + }, + + setMapTheme(theme) { + this.map.setOptions({ styles: CC_MAP_THEMES[theme] || CC_MAP_THEMES.dark }); + localStorage.setItem('cc_map_theme', theme); + document.querySelectorAll('.cc-theme-btn').forEach(b => { + b.classList.toggle('active', b.dataset.theme === theme); + }); + }, + + /* ── Clock ───────────────────────────────────────────────── */ + + startClock() { + const tick = () => { + document.getElementById('cc-clock').textContent = + new Date().toLocaleTimeString('es-CO'); + }; + tick(); + setInterval(tick, 1000); + }, + + /* ── Polling ─────────────────────────────────────────────── */ + + poll() { + this.pollStats(); + this.pollMap(); + this.pollAlerts(); + }, + + pollStats() { + this._get('command/stats').then(d => { + if (!d) return; + document.getElementById('kpi-active').textContent = d.active_trips; + document.getElementById('kpi-free').textContent = d.available_drivers; + document.getElementById('kpi-sos').textContent = d.open_sos; + document.getElementById('kpi-today').textContent = d.trips_today; + const box = document.getElementById('kpi-sos-box'); + d.open_sos > 0 ? box.classList.add('sos-active') : box.classList.remove('sos-active'); + }); + }, + + pollMap() { + this._get('command/live-map').then(d => { + if (!d) return; + this.updateMap(d); + this.updateTripsList(d.trips || []); + document.getElementById('kpi-active').textContent = (d.trips || []).length; + document.getElementById('kpi-free').textContent = (d.drivers || []).length; + }); + }, + + pollAlerts() { + this._get('command/alerts').then(d => { + if (!d) return; + const sos = d.sos || []; + if (sos.length > this.lastSosCount && this.lastSosCount >= 0) { + this.playAlert(); + const sec = document.getElementById('cc-sos-section'); + sec.classList.add('sos-flash'); + setTimeout(() => sec.classList.remove('sos-flash'), 3000); + } + this.lastSosCount = sos.length; + document.getElementById('sos-count-badge').textContent = sos.length; + this.updateSosList(sos); + }); + }, + + /* ── Map markers ─────────────────────────────────────────── */ + + carIcon(color) { + const svg = ` + + + + + + + + + `; + return { + url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg), + scaledSize: new google.maps.Size(24, 40), + anchor: new google.maps.Point(12, 38), + }; + }, + + updateMap(data) { + const active = new Set(); + + (data.trips || []).forEach(t => { + if (!t.driver_lat || !t.driver_lng) return; + const key = 'trip_' + t.id; + active.add(key); + const pos = { lat: parseFloat(t.driver_lat), lng: parseFloat(t.driver_lng) }; + if (this.markers[key]) { + const prev = this.markers[key].getPosition(); + this.markers[key]._prevPos = { lat: prev.lat(), lng: prev.lng() }; + this.markers[key].setPosition(pos); + this.markers[key]._tripData = t; + } else { + const m = new google.maps.Marker({ + position: pos, + map: this.map, + title: (t.driver_name || 'Conductor') + ' → ' + (t.passenger_name || 'Pasajero'), + icon: this.carIcon('#FF6F00'), + }); + m._tripData = t; + m._prevPos = null; + m.addListener('click', () => this.selectTrip(t.id)); + m.addListener('mouseover', () => this._showTripHover(m)); + m.addListener('mouseout', () => { if (this._hoverWin) this._hoverWin.close(); }); + this.markers[key] = m; + } + }); + + (data.drivers || []).forEach(d => { + if (!d.lat || !d.lng) return; + const key = 'drv_' + d.user_id; + active.add(key); + const pos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) }; + if (this.markers[key]) { + this.markers[key].setPosition(pos); + } else { + const m = new google.maps.Marker({ + position: pos, + map: this.map, + title: (d.display_name || 'Conductor') + ' (Disponible)', + icon: this.carIcon('#00cc66'), + }); + m.addListener('click', () => { + if (!this._infoWin) this._infoWin = new google.maps.InfoWindow(); + this._infoWin.setContent( + `
+ ${d.display_name || 'Conductor'}
+ Disponible
+ Lat: ${parseFloat(d.lat).toFixed(5)}, Lng: ${parseFloat(d.lng).toFixed(5)} +
` + ); + this._infoWin.open(this.map, m); + }); + this.markers[key] = m; + } + }); + + Object.keys(this.markers).forEach(k => { + if (!active.has(k)) { + this.markers[k].setMap(null); + delete this.markers[k]; + } + }); + }, + + /* ── Sidebar lists ───────────────────────────────────────── */ + + updateSosList(sos) { + const el = document.getElementById('sos-list'); + if (!sos.length) { + el.innerHTML = '
Sin alertas activas
'; + return; + } + el.innerHTML = sos.map(s => ` +
+
+ 🚨 ${this._esc(s.user_name || 'Usuario desconocido')} + ${this._ago(s.created_at)} +
+
${this._esc(s.type || 'SOS')} — Viaje #${s.trip_id || '—'}
+ ${s.description ? `
${this._esc(s.description)}
` : ''} +
+ ${s.trip_id ? `` : ''} + +
+
+ `).join(''); + }, + + updateTripsList(trips) { + const el = document.getElementById('trips-list'); + if (!trips.length) { + el.innerHTML = '
Sin viajes activos
'; + return; + } + el.innerHTML = trips.map(t => ` +
+
${this._esc(t.driver_name || 'Conductor #' + t.driver_id)}
+
👤 ${this._esc(t.passenger_name || 'Pasajero #' + t.passenger_id)}
+
📍 ${this._esc((t.pickup_address || '').substring(0, 42))}
+
${t.status}${!t.driver_lat ? ' · Sin GPS' : ''}
+
+ `).join(''); + }, + + /* ── Trip detail ─────────────────────────────────────────── */ + + selectTrip(id) { + this.selectedTrip = id; + this._get('command/trip/' + id).then(data => { + if (!data || !data.trip) return; + this._showDetail(data.trip); + }); + }, + + _showDetail(t) { + this._curTrip = t; + document.getElementById('cc-panel-title').textContent = 'Viaje #' + t.id + (t.status ? ' · ' + t.status : ''); + document.getElementById('cc-panel-info').innerHTML = ` +
${this._esc(t.driver_name || '#' + t.driver_id)}
+
${this._esc(t.passenger_name || '#' + t.passenger_id)}
+
${this._esc(t.driver_phone || 'N/A')}
+
${this._esc(t.passenger_phone || 'N/A')}
+
${this._esc(t.dropoff_address || 'Consultando...')}
+
${this._esc(t.status || 'N/A')}
+
${t.started_at ? this._time(t.started_at) : 'N/A'}
+ `; + document.getElementById('cc-panel-pattern').innerHTML = this._patternHtml(t); + document.getElementById('cc-panel-btns').innerHTML = ` + + + + + + + `; + document.getElementById('cc-trip-panel').style.display = ''; + if (!t.dropoff_address && t.dropoff_lat && t.dropoff_lng) + this._geocodeAndUpdate(t.dropoff_lat, t.dropoff_lng, 'cc-panel-dest'); + }, + + closeDetail() { + document.getElementById('cc-trip-panel').style.display = 'none'; + this.selectedTrip = null; + this._curTrip = null; + }, + + /* ── Actions ─────────────────────────────────────────────── */ + + callDriver() { + const t = this._curTrip; + if (!t) return; + if (t.driver_phone) window.location.href = 'tel:' + t.driver_phone; + else this._toast('Teléfono del conductor no disponible', 'warning'); + }, + + callPassenger() { + const t = this._curTrip; + if (!t) return; + if (t.passenger_phone) window.location.href = 'tel:' + t.passenger_phone; + else this._toast('Teléfono del pasajero no disponible', 'warning'); + }, + + callPolice() { + if (!confirm('¿Confirmar llamada de emergencia a la Policía Nacional (123)?')) return; + window.location.href = 'tel:123'; + if (this._curTrip) { + this._post('command/incident', { + trip_id: this._curTrip.id, + type: 'police_called', + description: 'Operador llamó a la Policía desde consola de comando', + }); + } + }, + + openProtocol() { + if (!this._curTrip) return; + document.querySelectorAll('.proto-opt').forEach(b => b.classList.remove('selected')); + document.getElementById('proto-notes').value = ''; + document.getElementById('cc-modal-protocol').classList.add('open'); + }, + + selectProtocol(btn) { + document.querySelectorAll('.proto-opt').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + }, + + confirmProtocol() { + const sel = document.querySelector('.proto-opt.selected'); + if (!sel) { this._toast('Selecciona un protocolo', 'warning'); return; } + const protocol = sel.dataset.p; + const notes = document.getElementById('proto-notes').value.trim(); + this._post('command/incident', { + trip_id: this._curTrip.id, + type: 'protocol_activated', + description: notes, + protocol: protocol, + }).then(() => { + this.closeModal('protocol'); + this._toast('Protocolo "' + protocol + '" activado', 'success'); + this.poll(); + }); + }, + + openMessage() { + if (!this._curTrip) return; + document.getElementById('msg-body').value = ''; + document.getElementById('cc-modal-message').classList.add('open'); + }, + + sendMessage() { + const t = this._curTrip; + const to = document.getElementById('msg-to').value; + const msg = document.getElementById('msg-body').value.trim(); + if (!msg) { this._toast('Escribe un mensaje', 'warning'); return; } + const toId = to === 'driver' ? t.driver_id : t.passenger_id; + this._post('command/message', { + trip_id: t.id, + to_user_id: toId, + message: msg, + }).then(() => { + this.closeModal('message'); + this._toast('Mensaje enviado', 'success'); + }); + }, + + blockDriver() { + const t = this._curTrip; + if (!t) return; + const name = t.driver_name || '#' + t.driver_id; + const reason = prompt('¿Razón para bloquear al conductor ' + name + '?'); + if (reason === null) return; + this._post('command/block-driver', { driver_id: t.driver_id, reason }).then(() => { + this._toast('Conductor bloqueado', 'warning'); + this.closeDetail(); + this.poll(); + }); + }, + + resolveAlert(id) { + const notes = prompt('Notas de resolución (opcional):') ?? ''; + this._post('command/resolve/' + id, { notes }).then(() => { + this._toast('Alerta resuelta', 'success'); + this.pollAlerts(); + this.pollStats(); + }); + }, + + closeModal(name) { + document.getElementById('cc-modal-' + name).classList.remove('open'); + }, + + /* ── Hover tooltip ───────────────────────────────────────────── */ + + _showTripHover(m) { + const td = m._tripData; + if (!this._hoverWin) this._hoverWin = new google.maps.InfoWindow({ disableAutoPan: true }); + if (!this._geocoder) this._geocoder = new google.maps.Geocoder(); + + const driverName = this._esc(td.driver_name || 'Conductor'); + const paxName = this._esc(td.passenger_name || 'Pasajero'); + const curLat = parseFloat(td.driver_lat); + const curLng = parseFloat(td.driver_lng); + const curKey = curLat.toFixed(4) + ',' + curLng.toFixed(4); + const destKey = (td.dropoff_lat || '') + ',' + (td.dropoff_lng || ''); + + const dirStr = m._prevPos + ? this._bearingDir(this._bearing(m._prevPos.lat, m._prevPos.lng, curLat, curLng)) + : null; + const distMi = (td.dropoff_lat && td.dropoff_lng) + ? (this._calcDist(curLat, curLng, parseFloat(td.dropoff_lat), parseFloat(td.dropoff_lng)) * 0.621371).toFixed(1) + : null; + + const refresh = () => { + if (!this._hoverWin.getMap()) return; + const curAddr = this._geoCache[curKey] || (curLat.toFixed(4) + ', ' + curLng.toFixed(4)); + const destAddr = this._geoCache[destKey] || 'Cargando...'; + this._hoverWin.setContent(this._hoverHtml(driverName, paxName, curAddr, destAddr, distMi, dirStr)); + }; + + if (!this._geoCache[curKey]) { + this._geocoder.geocode({ location: { lat: curLat, lng: curLng } }, (res, st) => { + this._geoCache[curKey] = (st === 'OK' && res[0]) ? res[0].formatted_address : curKey; + refresh(); + }); + } + if (!this._geoCache[destKey] && td.dropoff_lat && td.dropoff_lng) { + this._geocoder.geocode({ location: { lat: parseFloat(td.dropoff_lat), lng: parseFloat(td.dropoff_lng) } }, (res, st) => { + this._geoCache[destKey] = (st === 'OK' && res[0]) ? res[0].formatted_address : destKey; + refresh(); + }); + } + + refresh(); + this._hoverWin.open(this.map, m); + }, + + _hoverHtml(driver, pax, curPos, dest, distMi, dir) { + const arrow = dir ? this._dirArrow(dir) : ''; + const row = (label, val) => + '
' + + '' + label + '' + + '' + val + '
'; + return '
' + + '
🚗 ' + driver + '
' + + row('Pasajero', '👤 ' + pax) + + row('Posición', curPos) + + row('Destino', this._esc(dest)) + + (distMi ? row('Distancia', distMi + ' mi') : '') + + (dir ? row('Dirección', arrow + ' ' + this._esc(dir)) : '') + + '
'; + }, + + _dirArrow(dir) { + const map = { 'N':'↑','NE':'↗','E':'→','SE':'↘','S':'↓','SO':'↙','O':'←','NO':'↖' }; + return (map[dir.split(' ')[0]] || '↗'); + }, + + /* ── Pattern analysis ────────────────────────────────────────── */ + + _patternHtml(t) { + if (!t.dropoff_lat || !t.dropoff_lng || !t.current_lat || !t.current_lng) { + return '
Análisis de Ruta
' + + '
Sin datos de posición actual
'; + } + const dist = this._calcDist( + parseFloat(t.current_lat), parseFloat(t.current_lng), + parseFloat(t.dropoff_lat), parseFloat(t.dropoff_lng) + ); + let cls, label; + if (dist < 0.5) { cls = 'cc-p-ok'; label = '✔ Llegando al destino'; } + else if (dist < 8) { cls = 'cc-p-ok'; label = '✔ En ruta normal'; } + else if (dist < 20) { cls = 'cc-p-warn'; label = '⚠ Distancia elevada — verificar'; } + else { cls = 'cc-p-alert'; label = '✘ Posible desvío de ruta'; } + let timeRow = ''; + if (t.started_at) { + const mins = Math.floor((Date.now() - new Date(t.started_at).getTime()) / 60000); + timeRow = '
Tiempo en viaje' + mins + ' min
'; + } + return '
' + + '
Análisis de Ruta
' + + '
Dist. al destino' + dist.toFixed(1) + ' km
' + + timeRow + + '
' + label + '
' + + '
'; + }, + + _calcDist(lat1, lng1, lat2, lng2) { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + }, + + _bearing(lat1, lng1, lat2, lng2) { + const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180; + const Δλ = (lng2 - lng1) * Math.PI/180; + const y = Math.sin(Δλ) * Math.cos(φ2); + const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ); + return ((Math.atan2(y, x) * 180/Math.PI) + 360) % 360; + }, + + _bearingDir(deg) { + const dirs = ['N','NE','E','SE','S','SO','O','NO']; + return dirs[Math.round(deg / 45) % 8] + ' (' + Math.round(deg) + '°)'; + }, + + _geocodeAndUpdate(lat, lng, elId) { + if (!this._geocoder) this._geocoder = new google.maps.Geocoder(); + const key = lat + ',' + lng; + if (this._geoCache[key]) { + const el = document.getElementById(elId); + if (el) el.textContent = this._geoCache[key]; + return; + } + this._geocoder.geocode({ location: { lat: parseFloat(lat), lng: parseFloat(lng) } }, (results, status) => { + const addr = (status === 'OK' && results[0]) ? results[0].formatted_address : lat + ', ' + lng; + this._geoCache[key] = addr; + const el = document.getElementById(elId); + if (el) el.textContent = addr; + }); + }, + + /* ── Audio alert ─────────────────────────────────────────── */ + + playAlert() { + try { + if (!this.audioCtx) + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + [0, 0.45, 0.9].forEach(delay => { + const o = this.audioCtx.createOscillator(); + const g = this.audioCtx.createGain(); + o.connect(g); g.connect(this.audioCtx.destination); + o.type = 'square'; + o.frequency.value = 880; + const t = this.audioCtx.currentTime + delay; + g.gain.setValueAtTime(0.12, t); + g.gain.exponentialRampToValueAtTime(0.001, t + 0.35); + o.start(t); o.stop(t + 0.35); + }); + } catch (_) {} + }, + + /* ── Helpers ─────────────────────────────────────────────── */ + + _get(endpoint) { + return fetch(ccConfig.rest + endpoint, { + headers: { 'X-WP-Nonce': ccConfig.nonce }, + }).then(r => r.ok ? r.json() : null).catch(() => null); + }, + + _post(endpoint, data) { + return fetch(ccConfig.rest + endpoint, { + method: 'POST', + headers: { 'X-WP-Nonce': ccConfig.nonce, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }).then(r => r.json()).catch(() => null); + }, + + _toast(msg, type = 'info') { + const el = document.createElement('div'); + el.className = 'cc-toast t-' + type; + el.textContent = msg; + document.body.appendChild(el); + requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show'))); + setTimeout(() => { + el.classList.remove('show'); + setTimeout(() => el.remove(), 350); + }, 3000); + }, + + _ago(dt) { + const m = Math.floor((Date.now() - new Date(dt).getTime()) / 60000); + if (m < 1) return 'ahora'; + if (m < 60) return m + 'min'; + return Math.floor(m / 60) + 'h'; + }, + + _time(dt) { return new Date(dt).toLocaleTimeString('es-CO'); }, + + _esc(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }, +}; + +/* Wait for google.maps.Map, then give it 1500ms to stabilize before init */ +(function waitForMaps(attempts) { + if (CC.map) return; + if (window.google && window.google.maps && typeof window.google.maps.Map === 'function') { + setTimeout(function() { if (!CC.map) CC.init(); }, 1500); + } else if (attempts < 40) { + setTimeout(function(){ waitForMaps(attempts + 1); }, 250); + } +})(0); diff --git a/docs/superpowers/plans/2026-05-30-security-implementation.md b/docs/superpowers/plans/2026-05-30-security-implementation.md new file mode 100644 index 0000000..e3d62db --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-security-implementation.md @@ -0,0 +1,583 @@ +# AutoBooking Security — Plan de Implementación + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implementar seguridad anti-spam, anti-ghost-registration, rate limiting, IP geolocation backup, documentos de conductor, y eliminar la API key de Google Maps del código fuente. + +**Architecture:** Plugin nuevo `autobooking-security` centraliza rate limiting e IP blocking. Plugins existentes `autobooking-admin-dashboard` y `autobooking-geo-restrict` se modifican quirúrgicamente. Snippets #14, #15, #18, #19 NO se tocan. + +**Tech Stack:** PHP 8.x, WordPress REST API, MariaDB, hCaptcha (hcaptcha.com), ip-api.com, WordPress transients. + +**Servidor:** `pi@ssh.famromdon.online:2230` pw `Geronimo6&8` | Container: `autobookingonline-wordpress-autobooking-1` | Plugins: `/var/www/html/wp-content/plugins/` | Local: `D:\Proyectos Software\AutoBooking\` + +**Deploy estándar** (usar en cada tarea): +```powershell +$psftp = "C:\Program Files\PuTTY\psftp.exe" +$plink = "C:\Program Files\PuTTY\plink.exe" +$cmds = "$env:TEMP\sftp_cmd.txt" +# 1. subir archivo local → /tmp/archivo.php +[System.IO.File]::WriteAllText($cmds, "put `"D:\Proyectos Software\AutoBooking\ARCHIVO.php`" /tmp/ARCHIVO.php`nbye", (New-Object System.Text.UTF8Encoding $false)) +& $psftp -pw "Geronimo6&8" -P 2230 -b $cmds pi@ssh.famromdon.online +# 2. mover al container +echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker cp /tmp/ARCHIVO.php autobookingonline-wordpress-autobooking-1:/var/www/html/wp-content/plugins/PLUGIN/ARCHIVO.php' +``` + +--- + +## Prerequisito manual (ANTES de empezar) + +- [ ] Ir a **hcaptcha.com** → crear cuenta gratuita → crear sitio `autobooking.online` → copiar **Site Key** y **Secret Key** +- [ ] **Google Cloud Console** → Credenciales → key `AIzaSyD3VSlYZvDEbbSKUhEFRdUD5rU1JWXX03Q` → agregar restricción HTTP referrer: `*.autobooking.online/*` + +--- + +## Tarea 1: Plugin autobooking-security — Tablas DB + Rate Limiting + +**Archivos:** Crear `autobooking-security.php` → subir a `/var/www/html/wp-content/plugins/autobooking-security/autobooking-security.php` + +- [ ] **1.1 Crear el archivo del plugin** en `D:\Proyectos Software\AutoBooking\autobooking-security.php`: + +```php +get_charset_collate(); + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_rate_limits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL DEFAULT '', + endpoint VARCHAR(80) NOT NULL DEFAULT '', + hits INT UNSIGNED NOT NULL DEFAULT 1, + window_start DATETIME NOT NULL, + PRIMARY KEY (id), KEY ip_ep_win (ip, endpoint, window_start) + ) $charset;" ); + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_blocked_ips ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL DEFAULT '', + reason VARCHAR(120) NOT NULL DEFAULT '', + blocked_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + PRIMARY KEY (id), UNIQUE KEY ip (ip), KEY ip_exp (ip, expires_at) + ) $charset;" ); + dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_driver_documents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + driver_id BIGINT UNSIGNED NOT NULL, + doc_type ENUM('license','insurance','vehicle_photo') NOT NULL, + file_url VARCHAR(512) NOT NULL DEFAULT '', + expiry_date DATE NULL, + status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending', + reviewed_by BIGINT UNSIGNED NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), KEY driver_doc (driver_id, doc_type), KEY status (status) + ) $charset;" ); +} + +/* ── HELPERS ──────────────────────────────────────────────── */ +function abs_get_ip() { + $ip = ''; + if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { + $parts = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ); + $ip = trim( $parts[0] ); + } + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + } + return sanitize_text_field( $ip ); +} + +function abs_is_whitelisted( $ip ) { + $raw = get_option( 'ab_sec_whitelist', '' ); + if ( ! $raw ) return false; + return in_array( $ip, array_map( 'trim', explode( "\n", $raw ) ), true ); +} + +function abs_is_blocked( $ip ) { + global $wpdb; + return (bool) $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}ab_blocked_ips WHERE ip = %s AND expires_at > %s", + $ip, current_time( 'mysql' ) + ) ); +} + +function abs_block_ip( $ip, $reason, $hours ) { + global $wpdb; + $wpdb->query( $wpdb->prepare( + "INSERT INTO {$wpdb->prefix}ab_blocked_ips (ip, reason, blocked_at, expires_at) VALUES (%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE reason=VALUES(reason), blocked_at=VALUES(blocked_at), expires_at=VALUES(expires_at)", + $ip, $reason, current_time('mysql'), date('Y-m-d H:i:s', time() + $hours * 3600) + ) ); +} + +function abs_check_rate( $ip, $endpoint, $limit, $window_sec, $block_hours = 0 ) { + if ( ! $ip || abs_is_whitelisted( $ip ) ) return true; + if ( abs_is_blocked( $ip ) ) return false; + global $wpdb; $table = $wpdb->prefix . 'ab_rate_limits'; + $window = date( 'Y-m-d H:i:s', time() - $window_sec ); + $hits = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT SUM(hits) FROM $table WHERE ip=%s AND endpoint=%s AND window_start>=%s", + $ip, $endpoint, $window + ) ); + if ( $hits >= $limit ) { + if ( $block_hours > 0 ) abs_block_ip( $ip, "Rate limit: $endpoint", $block_hours ); + return false; + } + $minute = date( 'Y-m-d H:i:00' ); + $exists = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM $table WHERE ip=%s AND endpoint=%s AND window_start=%s", + $ip, $endpoint, $minute + ) ); + if ( $exists ) { + $wpdb->query( $wpdb->prepare( + "UPDATE $table SET hits=hits+1 WHERE ip=%s AND endpoint=%s AND window_start=%s", + $ip, $endpoint, $minute + ) ); + } else { + $wpdb->insert( $table, ['ip'=>$ip,'endpoint'=>$endpoint,'hits'=>1,'window_start'=>$minute] ); + } + return true; +} + +/* ── RATE LIMITING: login ─────────────────────────────────── */ +add_action( 'wp_login_failed', function( $u ) { + abs_check_rate( abs_get_ip(), 'login', 10, 900, 2 ); +} ); +add_action( 'login_init', function() { + if ( abs_is_blocked( abs_get_ip() ) ) + wp_die( 'Tu IP fue bloqueada temporalmente por demasiados intentos fallidos.', 'Bloqueado', ['response'=>429] ); +} ); + +/* ── RATE LIMITING: registro ──────────────────────────────── */ +add_filter( 'registration_errors', function( $errors, $login, $email ) { + if ( ! abs_check_rate( abs_get_ip(), 'register', 5, 3600, 24 ) ) + $errors->add( 'rate_limit', 'Error: Demasiados intentos desde tu IP. Intenta más tarde.' ); + return $errors; +}, 10, 3 ); + +/* ── RATE LIMITING: REST ──────────────────────────────────── */ +add_filter( 'rest_dispatch_request', function( $result, $request, $route, $handler ) { + if ( strpos( $route, '/autobooking/v1/' ) === false ) return $result; + if ( $request->get_method() === 'GET' ) return $result; + if ( ! abs_check_rate( abs_get_ip(), 'rest_post', 120, 60, 0 ) ) + return new WP_Error( 'rate_limited', 'Demasiadas solicitudes.', ['status'=>429] ); + return $result; +}, 10, 4 ); + +/* ── LIMPIEZA DIARIA ──────────────────────────────────────── */ +add_action( 'abs_daily_cleanup', function() { + global $wpdb; + $wpdb->query("DELETE FROM {$wpdb->prefix}ab_rate_limits WHERE window_start < DATE_SUB(NOW(), INTERVAL 2 DAY)"); + $wpdb->query("DELETE FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at < NOW()"); +} ); +if ( ! wp_next_scheduled('abs_daily_cleanup') ) wp_schedule_event( time(), 'daily', 'abs_daily_cleanup' ); + +/* ── HCAPTCHA ─────────────────────────────────────────────── */ +add_action( 'register_form', function() { + $key = get_option('ab_hcaptcha_site_key',''); + if ( ! $key ) return; + echo '
'; + echo ''; +} ); +add_filter( 'registration_errors', function( $errors, $login, $email ) { + $secret = get_option('ab_hcaptcha_secret',''); + if ( ! $secret ) return $errors; + $token = sanitize_text_field( $_POST['h-captcha-response'] ?? '' ); + if ( ! $token ) { $errors->add('captcha_missing','Error: Completa el captcha.'); return $errors; } + $res = wp_remote_post('https://api.hcaptcha.com/siteverify', ['body'=>['secret'=>$secret,'response'=>$token,'remoteip'=>abs_get_ip()],'timeout'=>10]); + if ( ! is_wp_error($res) ) { + $body = json_decode(wp_remote_retrieve_body($res), true); + if ( empty($body['success']) ) $errors->add('captcha_invalid','Error: Captcha inválido.'); + } + return $errors; +}, 20, 3 ); + +/* ── EMAIL CONFIRMATION ───────────────────────────────────── */ +add_action( 'user_register', function( $uid ) { + $user = get_userdata($uid); + if ( ! $user || ! in_array('driver_pending',(array)$user->roles,true) ) return; + $token = bin2hex(random_bytes(32)); + update_user_meta($uid,'ab_email_token',$token); + update_user_meta($uid,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400)); + update_user_meta($uid,'ab_email_confirmed',0); + $url = add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email')); + wp_mail($user->user_email,'Confirma tu email — AutoBooking', + "Hola {$user->display_name},\n\nConfirma tu email (válido 24h):\n\n$url\n\nAutoBooking"); +} ); +add_action( 'template_redirect', function() { + if ( ! is_page('driver-dashboard') && ! is_page('pending-driver') ) return; + if ( ! is_user_logged_in() ) return; + $user = wp_get_current_user(); $roles = (array)$user->roles; + if ( ! in_array('driver_pending',$roles,true) && ! in_array('driver',$roles,true) ) return; + if ( (int)get_user_meta($user->ID,'ab_email_confirmed',true) ) return; + $resend = esc_url(get_rest_url(null,'autobooking/v1/resend-confirmation')); + wp_die('
' + .'

Confirma tu email

' + .'

Revisa tu bandeja de entrada y haz clic en el enlace de confirmación.

' + .'
'.wp_nonce_field('wp_rest','_wpnonce',true,false) + .'' + .'
','Confirma tu email',['response'=>403]); +} ); + +/* ── REST: confirm-email, resend, docs, admin IPs ─────────── */ +add_action( 'rest_api_init', function() { + + register_rest_route('autobooking/v1','/confirm-email',[ + 'methods'=>'GET','permission_callback'=>'__return_true', + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; + $token = sanitize_text_field($req->get_param('token')); + if(!$token){wp_redirect(home_url('/?ab_confirm=invalid'));exit;} + $uid = $wpdb->get_var($wpdb->prepare( + "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_email_token' AND meta_value=%s LIMIT 1",$token)); + if(!$uid){wp_redirect(home_url('/?ab_confirm=invalid'));exit;} + if(strtotime(get_user_meta((int)$uid,'ab_email_token_expires',true))'POST','permission_callback'=>'is_user_logged_in', + 'callback'=>function(){ + $user=wp_get_current_user(); $token=bin2hex(random_bytes(32)); + update_user_meta($user->ID,'ab_email_token',$token); + update_user_meta($user->ID,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400)); + $url=add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email')); + wp_mail($user->user_email,'Confirma tu email — AutoBooking',"Nuevo enlace (válido 24h):\n\n$url"); + wp_redirect(home_url('/pending-driver/?ab_confirm=resent'));exit; + }, + ]); + + register_rest_route('autobooking/v1','/driver/upload-doc',[ + 'methods'=>'POST','permission_callback'=>'is_user_logged_in', + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $user=wp_get_current_user(); + $doc_type=sanitize_text_field($req->get_param('doc_type')); + if(!in_array($doc_type,['license','insurance','vehicle_photo'],true)) + return new WP_Error('invalid_type','Tipo inválido.',['status'=>400]); + if(empty($_FILES['file'])) return new WP_Error('no_file','Sin archivo.',['status'=>400]); + $file=$_FILES['file']; + if(!in_array($file['type'],['image/jpeg','image/png','application/pdf'],true)) + return new WP_Error('invalid_mime','Solo JPG, PNG o PDF.',['status'=>400]); + if($file['size']>5*1024*1024) return new WP_Error('file_too_large','Máx 5 MB.',['status'=>400]); + require_once ABSPATH.'wp-admin/includes/file.php'; + $up=wp_upload_dir(); $dir=$up['basedir'].'/ab-driver-docs/'.$user->ID; + wp_mkdir_p($dir); + $ext=pathinfo($file['name'],PATHINFO_EXTENSION); + $fname=$doc_type.'_'.time().'.'.$ext; + if(!move_uploaded_file($file['tmp_name'],$dir.'/'.$fname)) + return new WP_Error('upload_failed','Error al guardar.',['status'=>500]); + $url=$up['baseurl'].'/ab-driver-docs/'.$user->ID.'/'.$fname; + $data=['driver_id'=>$user->ID,'doc_type'=>$doc_type,'file_url'=>$url,'status'=>'pending','created_at'=>current_time('mysql')]; + $exists=$wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d AND doc_type=%s",$user->ID,$doc_type)); + if($exists) $wpdb->update("{$wpdb->prefix}ab_driver_documents",$data,['driver_id'=>$user->ID,'doc_type'=>$doc_type]); + else $wpdb->insert("{$wpdb->prefix}ab_driver_documents",$data); + return rest_ensure_response(['ok'=>true,'file_url'=>$url]); + }, + ]); + + register_rest_route('autobooking/v1','/driver/my-docs',[ + 'methods'=>'GET','permission_callback'=>'is_user_logged_in', + 'callback'=>function(){ + global $wpdb; $uid=get_current_user_id(); + return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare( + "SELECT doc_type,file_url,status,expiry_date FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$uid),ARRAY_A)?:[]]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/driver/(?P\d+)/docs',[ + 'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $id=absint($req['id']); + return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$id),ARRAY_A)?:[]]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/doc/(?P\d+)/review',[ + 'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $id=absint($req['id']); + $status=in_array($req->get_param('status'),['approved','rejected'],true)?$req->get_param('status'):'pending'; + $wpdb->update("{$wpdb->prefix}ab_driver_documents", + ['status'=>$status,'reviewed_by'=>get_current_user_id(),'reviewed_at'=>current_time('mysql')],['id'=>$id]); + return rest_ensure_response(['ok'=>true]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/blocked-ips',[ + 'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(){ + global $wpdb; + return rest_ensure_response([ + 'blocked'=>$wpdb->get_results("SELECT id,ip,reason,blocked_at,expires_at FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at>NOW() ORDER BY blocked_at DESC LIMIT 100",ARRAY_A)?:[], + 'blocked_24h'=>(int)$wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}ab_blocked_ips WHERE blocked_at>=DATE_SUB(NOW(),INTERVAL 24 HOUR)"), + ]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/blocked-ips/(?P\d+)/unblock',[ + 'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + global $wpdb; $wpdb->delete("{$wpdb->prefix}ab_blocked_ips",['id'=>absint($req['id'])]); + return rest_ensure_response(['ok'=>true]); + }, + ]); + + register_rest_route('autobooking/v1','/admin/security-settings/save',[ + 'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');}, + 'callback'=>function(WP_REST_Request $req){ + $body=$req->get_json_params(); + if(isset($body['whitelist'])) update_option('ab_sec_whitelist',sanitize_textarea_field($body['whitelist'])); + if(isset($body['hcaptcha_site_key'])) update_option('ab_hcaptcha_site_key',sanitize_text_field($body['hcaptcha_site_key'])); + if(isset($body['hcaptcha_secret'])) update_option('ab_hcaptcha_secret',sanitize_text_field($body['hcaptcha_secret'])); + return rest_ensure_response(['ok'=>true]); + }, + ]); +} ); +``` + +- [ ] **1.2 Subir y activar el plugin** + +```powershell +$psftp = "C:\Program Files\PuTTY\psftp.exe"; $plink = "C:\Program Files\PuTTY\plink.exe" +$cmds = "$env:TEMP\sftp1.txt" +[System.IO.File]::WriteAllText($cmds, "put `"D:\Proyectos Software\AutoBooking\autobooking-security.php`" /tmp/abs.php`nbye", (New-Object System.Text.UTF8Encoding $false)) +& $psftp -pw "Geronimo6&8" -P 2230 -b $cmds pi@ssh.famromdon.online +echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'mkdir -p /tmp/abs_plugin && cp /tmp/abs.php /tmp/abs_plugin/autobooking-security.php && docker exec autobookingonline-wordpress-autobooking-1 mkdir -p /var/www/html/wp-content/plugins/autobooking-security && docker cp /tmp/abs_plugin/autobooking-security.php autobookingonline-wordpress-autobooking-1:/var/www/html/wp-content/plugins/autobooking-security/autobooking-security.php' +``` + +- [ ] **1.3 Activar desde WP Admin** → `https://autobooking.online/wp-admin/plugins.php` → Activar "AutoBooking Security" + +- [ ] **1.4 Verificar tablas creadas** + +```powershell +echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker exec autobookingonline-wordpress-autobooking-1 php -r "require \"/var/www/html/wp-load.php\"; global \$wpdb; foreach([\"ab_rate_limits\",\"ab_blocked_ips\",\"ab_driver_documents\"] as \$t){ echo \$wpdb->get_var(\"SHOW TABLES LIKE \\\"{$wpdb->prefix}\$t\\\"\") ? \"OK \$t\n\" : \"MISSING \$t\n\"; }"' +``` +Esperado: `OK ab_rate_limits`, `OK ab_blocked_ips`, `OK ab_driver_documents` + +- [ ] **1.5 Commit** +```bash +git add autobooking-security.php && git commit -m "feat: autobooking-security plugin — rate limiting, hCaptcha, email confirmation, driver docs" +``` + +--- + +## Tarea 2: Eliminar GMAPS_KEY del código fuente + +**Archivos:** Modificar `autobooking-admin-dashboard.php` (local) + +- [ ] **2.1 Backup en servidor** +```powershell +echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker exec autobookingonline-wordpress-autobooking-1 cp /var/www/html/wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php /var/www/html/wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php.bak' +``` + +- [ ] **2.2 En el archivo local**, hacer 3 cambios quirúrgicos: + +**Cambio A** — línea 17, reemplazar la constante: +```php +// ANTES: +const GMAPS_KEY = 'AIzaSyD3VSlYZvDEbbSKUhEFRdUD5rU1JWXX03Q'; +// DESPUÉS (eliminar la línea, la key se lee de wp_options) +``` + +**Cambio B** — en `enqueue()`, reemplazar el uso de la constante en la URL de Google Maps: +```php +// ANTES: +'https://maps.googleapis.com/maps/api/js?key=' . self::GMAPS_KEY . '&libraries=places,drawing', +// DESPUÉS: +'https://maps.googleapis.com/maps/api/js?key=' . esc_attr( get_option( 'ab_gmaps_key', '' ) ) . '&libraries=places,drawing', +``` + +**Cambio C** — en `wp_localize_script`: +```php +// ANTES: +'gmaps_key' => self::GMAPS_KEY, +// DESPUÉS: +'gmaps_key' => esc_attr( get_option( 'ab_gmaps_key', '' ) ), +``` + +**Cambio D** — en `rest_settings_get()`: +```php +// ANTES: +'gmaps_key' => get_option( 'ab_gmaps_key', self::GMAPS_KEY ), +// DESPUÉS: +'gmaps_key' => get_option( 'ab_gmaps_key', '' ), +``` + +- [ ] **2.3 Desplegar** (mismo comando deploy con `autobooking-admin-dashboard.php`) + +- [ ] **2.4 Verificar** — en WP Admin → AutoBooking Admin → tab CONFIG, asegurarse que el campo "Google Maps Key" tiene el valor `AIzaSyD3VSlYZvDEbbSKUhEFRdUD5rU1JWXX03Q`. Si está vacío, pegarlo y guardar. + +- [ ] **2.5 Verificar en HTML** — Ver fuente de `/admin-dashboard/` (Ctrl+U) → buscar `AIzaSyD3` → NO debe aparecer como texto plano en el source (aparece como valor de option, no hardcoded). + +- [ ] **2.6 Commit** +```bash +git add autobooking-admin-dashboard.php && git commit -m "fix: Google Maps API key moved from hardcode to wp_options" +``` + +--- + +## Tarea 3: IP Geolocation en geo-restrict + +**Archivos:** Modificar `autobooking-geo-restrict.php` (local) + +- [ ] **3.1 Backup en servidor** +```powershell +echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker exec autobookingonline-wordpress-autobooking-1 cp /var/www/html/wp-content/plugins/autobooking-geo-restrict/autobooking-geo-restrict.php /var/www/html/wp-content/plugins/autobooking-geo-restrict/autobooking-geo-restrict.php.bak' +``` + +- [ ] **3.2 Agregar función helper** después de `ab_is_country_allowed()` (~línea 67): + +```php +function ab_get_country_from_ip( $ip ) { + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) return null; + $cache_key = 'ab_geo_ip_' . md5( $ip ); + $cached = get_transient( $cache_key ); + if ( $cached !== false ) return $cached ?: null; + $response = wp_remote_get( 'http://ip-api.com/json/' . rawurlencode( $ip ) . '?fields=countryCode,status', ['timeout'=>5] ); + if ( is_wp_error( $response ) ) { set_transient( $cache_key, '', 3600 ); return null; } + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $code = ( ! empty( $body['status'] ) && $body['status'] === 'success' ) ? strtoupper( $body['countryCode'] ?? '' ) : ''; + set_transient( $cache_key, $code, 86400 ); + return $code ?: null; +} + +function ab_get_request_ip() { + if ( function_exists( 'abs_get_ip' ) ) return abs_get_ip(); + $ip = ''; + if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); $ip = trim($parts[0]); } + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + return sanitize_text_field( $ip ); +} +``` + +- [ ] **3.3 Modificar el callback de `/geo/check`** — reemplazar el return final del callback: + +```php +// ANTES (las últimas líneas del callback): + return rest_ensure_response( [ + 'allowed' => $allowed, + 'country' => $country, + 'message' => $allowed ? '' : get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ), + ] ); + +// DESPUÉS: + if ( $allowed && $country ) { + $ip_country = ab_get_country_from_ip( ab_get_request_ip() ); + if ( $ip_country && $ip_country !== $country ) { + return rest_ensure_response(['allowed'=>false,'country'=>$country,'message'=>'Ubicación inconsistente. Verifica tu conexión.']); + } + } + return rest_ensure_response( [ + 'allowed' => $allowed, + 'country' => $country, + 'message' => $allowed ? '' : get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ), + ] ); +``` + +- [ ] **3.4 Desplegar** + +- [ ] **3.5 Verificar** +``` +GET https://autobooking.online/wp-json/autobooking/v1/geo/check?lat=40.7128&lng=-74.0060 +``` +Esperado: `{"allowed":true,"country":"US","message":""}` + +- [ ] **3.6 Commit** +```bash +git add autobooking-geo-restrict.php && git commit -m "feat: IP geolocation backup in geo/check endpoint" +``` + +--- + +## Tarea 4: Sección Seguridad en Admin Dashboard CONFIG + +**Archivos:** Modificar `autobooking-admin-dashboard.php` (HTML del panel CONFIG) + +- [ ] **4.1 Agregar card de Seguridad** en el HTML del panel `panel-settings`, después del card de tarifas (buscar `` que cierra el card de tarifas, ~línea 840): + +```php +
+
Seguridad
+
+ + +
+
+ + +
+
+ + +
+ + +
IPs bloqueadas
+

+
+
+``` + +- [ ] **4.2 Agregar JS** al final de `assets/admin-dashboard.js`: + +```javascript +// ── SECURITY ────────────────────────────────────────────────── +async function loadSecuritySettings() { + try { + const r = await apiFetch('/admin/blocked-ips'); + document.getElementById('sec-blocked-count').textContent = r.blocked_24h + ' IPs bloqueadas en las últimas 24h'; + const el = document.getElementById('sec-blocked-table'); + if (!r.blocked.length) { el.innerHTML = '

Sin IPs bloqueadas.

'; return; } + el.innerHTML = '' + + r.blocked.map(b => ``).join('') + + '
IPMotivoExpira
${b.ip}${b.reason}${b.expires_at}
'; + } catch(e) {} +} +window.absUnblock = async id => { await apiFetch('/admin/blocked-ips/'+id+'/unblock',{method:'POST'}); loadSecuritySettings(); }; +document.getElementById('btn-save-sec')?.addEventListener('click', async () => { + await apiFetch('/admin/security-settings/save', {method:'POST', body: JSON.stringify({ + whitelist: document.getElementById('sec-whitelist').value, + hcaptcha_site_key: document.getElementById('sec-hcaptcha-site').value, + hcaptcha_secret: document.getElementById('sec-hcaptcha-secret').value, + })}); + const el = document.getElementById('sec-saved'); el.style.display='block'; setTimeout(()=>el.style.display='none',3000); +}); +document.querySelectorAll('.abad-tab').forEach(btn => { + if (btn.dataset.tab === 'settings') btn.addEventListener('click', loadSecuritySettings); +}); +``` + +- [ ] **4.3 Desplegar** `autobooking-admin-dashboard.php` + `assets/admin-dashboard.js` + +- [ ] **4.4 Verificar** — Admin dashboard → tab CONFIG → ver sección Seguridad → ingresar hCaptcha keys → Guardar → confirmar mensaje "Guardado." + +- [ ] **4.5 Commit** +```bash +git add autobooking-admin-dashboard.php && git commit -m "feat: security section in admin CONFIG tab" +``` + +--- + +## Verificación final + +- [ ] Bot no puede crear más de 5 cuentas/hora → al intento 6 aparece error de rate limit +- [ ] Conductor sin email confirmado → al entrar a `/pending-driver/` ve pantalla "Confirma tu email" +- [ ] Clic en enlace del email → redirige a `/pending-driver/?ab_confirm=ok` +- [ ] `GET /wp-json/autobooking/v1/geo/check?lat=40.7&lng=-74.0` → `{"allowed":true,...}` +- [ ] Ver fuente de página admin → `AIzaSyD3` no aparece hardcoded +- [ ] Login con usuario que tenga rol driver + customer → redirige a `/choose-role/` ✓ (no tocado) diff --git a/docs/superpowers/specs/2026-05-30-security-design.md b/docs/superpowers/specs/2026-05-30-security-design.md new file mode 100644 index 0000000..911ad55 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-security-design.md @@ -0,0 +1,229 @@ +# AutoBooking Security — Diseño Opción B + +**Fecha:** 2026-05-30 +**Estado:** Aprobado por usuario +**Alcance:** Anti-spam, anti-ghost registrations, rate limiting, IP geolocation backup, documentos de conductor + +--- + +## 1. Contexto y problema + +AutoBooking es una plataforma de transporte en WordPress (autobooking.online). Los problemas identificados: + +- **Cuentas fantasma:** cualquier bot puede registrarse como conductor o pasajero sin verificación +- **Spam en chat:** `wp_autobooking_chat` sin rate limiting +- **API Key de Google Maps expuesta** en código fuente PHP y en HTML del frontend +- **GPS spoofeable:** el plugin geo-restrict confía 100% en coordenadas enviadas por el cliente +- **Sin rate limiting** en ningún endpoint REST ni en login/registro + +Funcionalidades existentes que se preservan sin cambios: +- Selección de rol al login (Snippets #15 + #18: `after-login` → `choose-role`) +- Páginas de registro con diseño Forminator +- Plugin `autobooking-roles` v1.1.0 (roles driver, driver_pending, corporate_admin) +- Todos los plugins de dashboard (driver, passenger, corporate, admin) + +--- + +## 2. Arquitectura + +### Componentes nuevos + +| Componente | Tipo | Propósito | +|---|---|---| +| `autobooking-security.php` | Plugin nuevo | Central de seguridad: rate limiting, nonces, IP geo | +| `wp_ab_rate_limits` | Tabla DB | Registro de hits por IP/endpoint | +| `wp_ab_blocked_ips` | Tabla DB | IPs bloqueadas con TTL | +| `wp_ab_driver_documents` | Tabla DB | Documentos de conductor (licencia, seguro, foto) | +| hCaptcha en registro | Hook Forminator | Verificación anti-bot en registro de conductores | + +### Componentes modificados + +| Componente | Cambio | +|---|---| +| `autobooking-admin-dashboard.php` | Mover `GMAPS_KEY` a `wp_options`; mostrar documentos pendientes en tab Conductores | +| `autobooking-geo-restrict.php` | Agregar verificación de IP via ip-api.com con caché transient | + +--- + +## 3. Rate Limiting + +### Tabla `wp_ab_rate_limits` +```sql +id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY +ip VARCHAR(45) NOT NULL +endpoint VARCHAR(80) NOT NULL +hits INT UNSIGNED NOT NULL DEFAULT 1 +window_start DATETIME NOT NULL +KEY (ip, endpoint, window_start) +``` + +### Tabla `wp_ab_blocked_ips` +```sql +id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY +ip VARCHAR(45) NOT NULL UNIQUE +reason VARCHAR(120) NOT NULL DEFAULT '' +blocked_at DATETIME NOT NULL +expires_at DATETIME NOT NULL +KEY (ip, expires_at) +``` + +### Límites configurados + +| Endpoint / Acción | Límite | Ventana | Bloqueo automático | +|---|---|---|---| +| Registro de usuario | 5 intentos | 60 min | 24 h | +| `wp_login_failed` | 10 fallos | 15 min | 2 h | +| `POST /autobooking/v1/*` | 120 requests | 1 min | 30 min | +| Chat (write) | 1 mensaje | 2 seg | 10 min | + +### Implementación +- Hook `register_post` → verificar IP antes de crear usuario +- Hook `wp_login_failed` → contar fallos de login por IP +- Filter `rest_dispatch_request` → contar requests REST por IP +- Helper `ab_sec_check_rate( $ip, $endpoint, $limit, $window_sec )` → bool +- Helper `ab_sec_block_ip( $ip, $reason, $hours )` → void +- IPs en whitelist (configurables en tab CONFIG del admin dashboard) nunca se bloquean + +--- + +## 4. Anti-Ghost Registration + +### Flujo de registro de conductor + +``` +1. Usuario llena formulario Forminator en /driver-registration/ +2. hCaptcha valida (verificación server-side) +3. Rate limit: máx 5 registros / hora / IP +4. WordPress crea usuario con rol driver_pending +5. Email de confirmación enviado → cuenta inactiva hasta confirmar +6. Conductor sube 3 documentos desde /pending-driver/ +7. Admin aprueba en dashboard → rol pasa a driver +``` + +### hCaptcha +- Servicio gratuito (hcaptcha.com) — requiere crear cuenta y obtener site key + secret key +- Hook `registration_errors` para verificar `h-captcha-response` via `POST https://api.hcaptcha.com/siteverify` +- Si falla → error visible en el formulario, registro rechazado +- Site key y secret key guardadas en `wp_options`: `ab_hcaptcha_site_key`, `ab_hcaptcha_secret` + +### Email confirmation +- Token de confirmación en `wp_usermeta`: `ab_email_token` (SHA256 random), `ab_email_token_expires` (24h) +- Usuario con rol `driver_pending` sin email confirmado → redirigido a `/pending-driver/` con mensaje "Confirma tu email" +- Endpoint `GET /autobooking/v1/confirm-email?token=xxx` → valida token, marca `ab_email_confirmed = 1` +- Si el token expiró (>24h): mostrar página "Token expirado" con botón para reenviar email de confirmación (endpoint `POST /autobooking/v1/resend-confirmation`) + +### Tabla `wp_ab_driver_documents` +```sql +id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY +driver_id BIGINT UNSIGNED NOT NULL +doc_type ENUM('license','insurance','vehicle_photo') NOT NULL +file_url VARCHAR(512) NOT NULL DEFAULT '' +expiry_date DATE NULL +status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending' +reviewed_by BIGINT UNSIGNED NULL +reviewed_at DATETIME NULL +created_at DATETIME NOT NULL +KEY (driver_id, doc_type) +KEY (status) +``` + +### Regla de aprobación +- Conductor sube docs desde `/pending-driver/` — tipos permitidos: JPG, PNG, PDF (máx 5 MB por archivo), guardados en `wp-content/uploads/ab-driver-docs/{user_id}/` +- Admin ve docs en tab CONDUCTORES → cola de aprobación +- Botón "Aprobar conductor" solo habilitado si los 3 documentos existen +- Rechazo envía email automático con motivo al conductor + +--- + +## 5. IP Geolocation Backup + +### Modificación a `/geo/check` + +``` +1. Recibir lat/lng del cliente +2. Detectar país por bounding boxes (lógica existente — sin cambios) +3. Si país por GPS está en allowed list: + a. Obtener IP del request (X-Forwarded-For validado, o REMOTE_ADDR) + b. Si IP es privada/local (127.x, 10.x, 192.168.x) → confiar en GPS + c. Consultar caché: transient "ab_geo_ip_{md5(ip)}" TTL=86400s + d. Si no hay caché → llamar ip-api.com/json/{ip}?fields=countryCode + e. Si ip-api.com falla (timeout/error) → fail open, permitir acceso + f. Si país por IP != país por GPS → retornar allowed=false, mensaje "Ubicación inconsistente" +4. Retornar resultado normal +``` + +- ip-api.com: gratuito, 45 req/min — el caché de 24h por IP lo mantiene muy por debajo del límite +- El bloqueo por discrepancia es duro (no solo advertencia) + +--- + +## 6. Google Maps API Key + +### Problema +`const GMAPS_KEY = 'AIzaSyD3...'` hardcodeada en `autobooking-admin-dashboard.php:17` y expuesta en el HTML de la página de admin. + +### Solución en código +- Eliminar la constante `GMAPS_KEY` +- Leer siempre de `get_option('ab_gmaps_key', '')` — este campo ya existe en el tab CONFIG del admin dashboard +- El campo ya estaba en la UI; solo hay que eliminar el fallback a la constante + +### Acción manual requerida (fuera del código) +- Ir a Google Cloud Console → Credenciales → Restricciones de la key +- Agregar restricción HTTP: solo `*.autobooking.online/*` + +--- + +## 7. Modificaciones al Admin Dashboard + +### Tab CONDUCTORES +- Columna "Docs" en cola de aprobación: muestra `X/3` documentos subidos +- Botón "Aprobar" deshabilitado si hay documentos faltantes +- Modal muestra miniaturas de los 3 documentos con links para ver tamaño completo + +### Tab CONFIG — nueva sección "Seguridad" +- Campo: IPs en whitelist (textarea, una por línea) +- Tabla: IPs bloqueadas actualmente con columnas IP, Motivo, Expira, botón Desbloquear +- Contador: intentos bloqueados en las últimas 24 h + +--- + +## 8. Preservación de funcionalidad existente + +| Feature | Estado | Acción | +|---|---|---| +| Snippets #15 + #18 (choose-role) | Sin cambios | No tocar | +| Snippet #14 (login gating) | Sin cambios | No tocar | +| Snippet #19 (registro override) | Sin cambios | Solo agregar hCaptcha al hook | +| Páginas Forminator | Sin cambios | Solo agregar campo h-captcha-response | +| autobooking-roles plugin | Sin cambios | No tocar | +| Driver / Passenger / Corporate dashboards | Sin cambios | No tocar | + +--- + +## 9. Archivos a crear/modificar + +``` +CREAR: + wp-content/plugins/autobooking-security/autobooking-security.php + +MODIFICAR: + wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php + wp-content/plugins/autobooking-geo-restrict/autobooking-geo-restrict.php + +ACCIONES MANUALES: + 1. Google Cloud Console → restringir GMAPS_KEY a *.autobooking.online/* + 2. hcaptcha.com → crear cuenta gratuita, obtener site_key y secret_key + 3. Activar plugin autobooking-security desde WP Admin → Plugins +``` + +--- + +## 10. Criterios de éxito + +1. Un bot no puede crear más de 5 cuentas por hora desde la misma IP +2. Un conductor nuevo no puede acceder al dashboard sin confirmar su email +3. Un conductor no puede ser aprobado sin los 3 documentos subidos +4. GPS de USA + IP de Rusia → bloqueado con mensaje claro +5. La API Key de Google Maps no aparece en el HTML de ninguna página pública +6. El flujo de selección de rol al login (`choose-role`) sigue funcionando sin cambios +7. Las páginas de registro mantienen su diseño visual actual diff --git a/driver-dashboard.css b/driver-dashboard.css new file mode 100644 index 0000000..b428b8a --- /dev/null +++ b/driver-dashboard.css @@ -0,0 +1,1178 @@ +/* ================================================================ + Autobooking Driver Dashboard — CSS v2.0.0 + Basado EXACTAMENTE en el CSS de "Driver Dashboard Original" + (página WP ID 1135 —