From 1731855df8b4801886b51a2ce34b5ce9283533a3 Mon Sep 17 00:00:00 2001
From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com>
Date: Fri, 1 May 2026 11:14:09 -0500
Subject: [PATCH] =?UTF-8?q?feat:=20Query=20Runner=20=E2=80=94=20editor=20S?=
=?UTF-8?q?QL=20con=20historial,=20exportar=20CSV/JSON,=20=C3=A1rbol=20de?=
=?UTF-8?q?=20tablas?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
BOLD_INTEGRACION.md | 637 ++++++++++++++++++++
main.go | 3 +
migrations/migrate.go | 2 +
pkg/models/query_history.go | 45 ++
pkg/services/query_runner_service.go | 298 +++++++++
resources/views/conx_db.html | 9 +
resources/views/query_runner.html | 382 ++++++++++++
rest/controllers/query_runner_controller.go | 237 ++++++++
rest/controllers/seed_controller.go | 8 +
rest/routes/user.go | 12 +-
template_out/plantilla1.html | 149 +++++
11 files changed, 1781 insertions(+), 1 deletion(-)
create mode 100644 BOLD_INTEGRACION.md
create mode 100644 pkg/models/query_history.go
create mode 100644 pkg/services/query_runner_service.go
create mode 100644 resources/views/query_runner.html
create mode 100644 rest/controllers/query_runner_controller.go
create mode 100644 template_out/plantilla1.html
diff --git a/BOLD_INTEGRACION.md b/BOLD_INTEGRACION.md
new file mode 100644
index 0000000..34adf53
--- /dev/null
+++ b/BOLD_INTEGRACION.md
@@ -0,0 +1,637 @@
+# Integración Bold — Guía completa
+
+Guía de referencia para integrar Bold (pasarela de pagos colombiana) en cualquier sistema backend.
+Basada en experiencia real de producción con PHP; los conceptos aplican a cualquier lenguaje.
+
+---
+
+## Índice
+
+1. [Credenciales y modos](#1-credenciales-y-modos)
+2. [Crear un Payment Link (API)](#2-crear-un-payment-link-api)
+3. [Consultar estado de un link](#3-consultar-estado-de-un-link)
+4. [Callback URL (retorno del usuario)](#4-callback-url-retorno-del-usuario)
+5. [Webhook — configuración](#5-webhook--configuración)
+6. [Webhook — verificación de firma](#6-webhook--verificación-de-firma)
+7. [Webhook — procesamiento del evento](#7-webhook--procesamiento-del-evento)
+8. [Estrategia de referencias múltiples](#8-estrategia-de-referencias-múltiples)
+9. [Idempotencia](#9-idempotencia)
+10. [Esquema de base de datos](#10-esquema-de-base-de-datos)
+11. [Flujo completo de extremo a extremo](#11-flujo-completo-de-extremo-a-extremo)
+12. [Errores frecuentes y soluciones](#12-errores-frecuentes-y-soluciones)
+
+---
+
+## 1. Credenciales y modos
+
+Bold maneja dos entornos que se diferencian **únicamente por la API key**; el endpoint es el mismo.
+
+| Parámetro | Descripción |
+|--------------------|-----------------------------------------------------------|
+| `bold_api_key` | Clave de la API (diferente para test y producción) |
+| `bold_secret_key` | Clave secreta para verificar la firma del webhook |
+| `bold_mode` | `"test"` / `"production"` (para lógica propia del sistema)|
+
+> **Importante:** En modo **test** la clave secreta del webhook es una cadena vacía `""`.
+> En producción se usa la clave real del panel de Bold.
+
+### Obtener las credenciales
+
+1. Ingresa al panel de Bold → **Configuración → Integraciones → API**.
+2. Copia la **API Key** y la **Secret Key**.
+3. Para el webhook, registra tu URL en **Configuración → Webhooks**.
+
+---
+
+## 2. Crear un Payment Link (API)
+
+### Endpoint
+
+```
+POST https://integrations.api.bold.co/online/link/v1
+```
+
+### Headers
+
+```
+Authorization: x-api-key {bold_api_key}
+Content-Type: application/json
+Accept: application/json
+```
+
+### Payload JSON
+
+```json
+{
+ "amount_type": "CLOSE",
+ "amount": {
+ "currency": "COP",
+ "total_amount": 25000
+ },
+ "description": "Descripción del producto o servicio",
+ "callback_url": "https://tudominio.com/pago_exitoso.php",
+ "payer_email": "cliente@ejemplo.com",
+ "reference": "TU-REF-12345-1746123456"
+}
+```
+
+> **Moneda COP:** Bold no usa centavos. `total_amount: 25000` equivale a $25.000 pesos.
+
+#### Campos clave
+
+| Campo | Tipo | Descripción |
+|----------------|---------|-----------------------------------------------------------------------------|
+| `amount_type` | string | `"CLOSE"` = monto fijo. `"OPEN"` = el usuario puede ingresar el monto. |
+| `total_amount` | int | Valor en pesos COP (sin centavos). |
+| `callback_url` | string | URL a la que Bold redirige al usuario tras el pago. |
+| `payer_email` | string | Pre-rellena el email en la pasarela. |
+| `reference` | string | **Tu referencia personalizada.** Llega al webhook. Max 60 chars, alfanumérico + guiones. |
+
+### Respuesta exitosa (`HTTP 200`)
+
+```json
+{
+ "payload": {
+ "payment_link": "LNK_abc123xyz",
+ "url": "https://checkout.bold.co/payment/LNK_abc123xyz"
+ }
+}
+```
+
+- `payment_link` → ID interno de Bold (formato `LNK_xxx`). Guardarlo en tu base de datos.
+- `url` → URL a la que debes redirigir al usuario.
+
+### Ejemplo en PHP
+
+```php
+$payload = [
+ 'amount_type' => 'CLOSE',
+ 'amount' => ['currency' => 'COP', 'total_amount' => 25000],
+ 'description' => 'Inscripción al evento',
+ 'callback_url' => 'https://tudominio.com/pago_exitoso.php',
+ 'payer_email' => $email,
+ 'reference' => 'ERA-' . $usuarioId . '-' . time(),
+];
+
+$ch = curl_init('https://integrations.api.bold.co/online/link/v1');
+curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode($payload),
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: x-api-key ' . $apiKey,
+ 'Content-Type: application/json',
+ ],
+ CURLOPT_TIMEOUT => 15,
+ CURLOPT_SSL_VERIFYPEER => true,
+]);
+
+$response = curl_exec($ch);
+$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+curl_close($ch);
+
+if ($httpCode !== 200) {
+ // Manejar error
+}
+
+$data = json_decode($response, true);
+$linkId = $data['payload']['payment_link'];
+$paymentUrl = $data['payload']['url'];
+
+header('Location: ' . $paymentUrl);
+exit;
+```
+
+---
+
+## 3. Consultar estado de un link
+
+Útil si el webhook no llegó o como verificación extra en el callback.
+
+### Endpoint
+
+```
+GET https://integrations.api.bold.co/online/link/v1/{payment_link}
+```
+
+### Headers
+
+```
+Authorization: x-api-key {bold_api_key}
+Accept: application/json
+```
+
+### Respuesta relevante
+
+```json
+{
+ "payload": {
+ "payment_link": "LNK_abc123xyz",
+ "status": "ACTIVE",
+ "transactions": [
+ {
+ "status": "APPROVED",
+ "payment_method": "CARD",
+ "amount": 25000
+ }
+ ]
+ }
+}
+```
+
+---
+
+## 4. Callback URL (retorno del usuario)
+
+Cuando el usuario termina (o abandona) el pago, Bold lo redirige a tu `callback_url` con parámetros GET.
+
+> **Advertencia:** Bold puede usar distintos nombres de parámetro según la versión del checkout.
+> **Nunca confíes en un único parámetro.** Revisa todos los posibles:
+
+```
+bold-order-id, order_id, payment_link, payment_link_id, reference, id
+```
+
+### Estrategia robusta en PHP
+
+```php
+// Recolectar todos los candidatos posibles
+$candidateRefs = [];
+foreach (['bold-order-id', 'order_id', 'payment_link', 'reference', 'id'] as $key) {
+ $val = trim((string)($_GET[$key] ?? ''));
+ if ($val !== '') {
+ $candidateRefs[] = $val;
+ }
+}
+// Agregar lo guardado en sesión antes de la redirección
+if (!empty($_SESSION['bold_order_id'])) {
+ $candidateRefs[] = $_SESSION['bold_order_id'];
+}
+$candidateRefs = array_values(array_unique(array_filter($candidateRefs)));
+
+// Buscar usuario por cualquiera de las referencias
+$usuario = null;
+foreach ($candidateRefs as $ref) {
+ $usuarioId = buscarUsuarioIdPorReferencia($pdo, $ref);
+ if ($usuarioId) {
+ // Cargar el usuario y salir del loop
+ break;
+ }
+}
+
+// Respaldo: usar ID de sesión si no se encontró por referencia
+if (!$usuario && !empty($_SESSION['usuario_id'])) {
+ // Cargar por $_SESSION['usuario_id']
+}
+```
+
+> **Nota crítica:** La callback se dispara **antes** del webhook. No es seguro marcar un pago
+> como aprobado solo por el callback; el origen de verdad es el **webhook**.
+> En el callback solo confirma que el usuario regresó y muestra una pantalla de "procesando".
+
+---
+
+## 5. Webhook — configuración
+
+### Registrar la URL en Bold
+
+Panel de Bold → **Configuración → Webhooks** → agregar URL:
+
+```
+https://tudominio.com/webhook.php
+```
+
+### Eventos disponibles
+
+| Evento | Descripción |
+|------------------|------------------------------------|
+| `SALE_APPROVED` | Pago aprobado exitosamente |
+| `SALE_REJECTED` | Pago rechazado por la entidad |
+| `SALE_REVERSED` | Reverso / devolución |
+| `CHARGEBACK` | Contracargo iniciado |
+
+Para la mayoría de casos solo necesitas `SALE_APPROVED`.
+
+### Requisitos del receptor
+
+- Debe responder **HTTP 200** en **menos de 2 segundos**.
+- Si la respuesta demora más, Bold reintentará el envío.
+- La URL debe ser HTTPS en producción.
+
+---
+
+## 6. Webhook — verificación de firma
+
+Bold firma cada notificación con HMAC-SHA256. El header es `x-bold-signature`.
+
+### Algoritmo de verificación
+
+```
+1. Leer el body RAW (antes de hacer cualquier json_decode)
+2. Codificar en Base64: encoded = base64(rawBody)
+3. Calcular HMAC: computed = HMAC-SHA256(encoded, secretKey) → hexadecimal
+4. Comparar con el header x-bold-signature (timing-safe)
+```
+
+> **Modo test:** la `secretKey` es `""` (cadena vacía).
+> **Modo producción:** usa la clave del panel de Bold.
+
+### Implementación PHP
+
+```php
+$rawBody = (string) file_get_contents('php://input');
+$signatureHeader = $_SERVER['HTTP_X_BOLD_SIGNATURE'] ?? '';
+
+// Responder 200 ANTES de todo procesamiento
+http_response_code(200);
+header('Content-Type: application/json');
+header('Connection: close');
+$body = '{"ok":true}';
+header('Content-Length: ' . strlen($body));
+echo $body;
+if (function_exists('fastcgi_finish_request')) {
+ fastcgi_finish_request();
+} else {
+ flush();
+}
+
+// --- A partir de aquí el cliente ya recibió 200 ---
+
+if ($signatureHeader !== '') {
+ $keyForHmac = ($boldMode === 'production') ? $secretKey : '';
+ $encoded = base64_encode($rawBody);
+ $computed = hash_hmac('sha256', $encoded, $keyForHmac);
+
+ if (!hash_equals($computed, $signatureHeader)) {
+ // Firma inválida: loguear y salir
+ exit;
+ }
+}
+```
+
+---
+
+## 7. Webhook — procesamiento del evento
+
+### Estructura del payload JSON
+
+```json
+{
+ "id": "notif_uuid_unico",
+ "type": "SALE_APPROVED",
+ "subject": "TXN_xyz789",
+ "data": {
+ "payment_id": "TXN_xyz789",
+ "amount": {
+ "total": 25000,
+ "currency": "COP"
+ },
+ "payer_email": "cliente@ejemplo.com",
+ "metadata": {
+ "reference": "ERA-42-1746123456"
+ }
+ }
+}
+```
+
+### Campos a extraer
+
+| Campo | Ruta en JSON | Descripción |
+|----------------------------|---------------------------------------------|------------------------------------------|
+| `notification_id` | `event.id` | ID único de la notificación (idempotencia) |
+| `tipo` | `event.type` | Tipo de evento |
+| `payment_id` | `event.data.payment_id` o `event.subject` | ID de la transacción Bold |
+| `referencia` | `event.data.metadata.reference` | Tu referencia personalizada |
+| `payer_email` | `event.data.payer_email` | Email del pagador |
+| `amount` | `event.data.amount.total` | Monto en pesos COP |
+
+### Flujo de procesamiento
+
+```php
+$event = json_decode($rawBody, true);
+$notificationId = $event['id'] ?? '';
+$tipo = $event['type'] ?? '';
+$data = $event['data'] ?? [];
+
+$paymentId = $data['payment_id'] ?? ($event['subject'] ?? '');
+$referencia = $data['metadata']['reference'] ?? '';
+$email = $data['payer_email'] ?? '';
+
+// Solo procesar ventas aprobadas
+if ($tipo !== 'SALE_APPROVED') {
+ exit;
+}
+
+// Verificar idempotencia (ver sección 9)
+// Resolver usuario (ver sección 8)
+// Marcar pago en la base de datos
+// Enviar correo de confirmación
+```
+
+---
+
+## 8. Estrategia de referencias múltiples
+
+**Problema real:** Bold puede enviar en el webhook un `payment_id` diferente al `LNK_xxx`
+original del payment link. Si solo guardas `bold_order_id`, la búsqueda falla.
+
+**Solución:** tabla de historial de referencias. Cada vez que aparece una referencia nueva
+asociada a un usuario, se registra.
+
+### Tabla `usuarios_referencias_pago`
+
+```sql
+CREATE TABLE IF NOT EXISTS `usuarios_referencias_pago` (
+ `id` INT PRIMARY KEY AUTO_INCREMENT,
+ `usuario_id` INT NOT NULL,
+ `referencia` VARCHAR(120) NOT NULL,
+ `tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida',
+ `origen` VARCHAR(40) NOT NULL DEFAULT 'sistema',
+ `creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY `uq_referencia` (`referencia`),
+ INDEX `idx_usuario` (`usuario_id`),
+ CONSTRAINT `fk_ref_usuario`
+ FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+### Cuándo registrar referencias
+
+| Momento | Referencias a guardar | `tipo` |
+|----------------------|--------------------------------------------------------|------------------|
+| Al crear el link | `LNK_xxx` (payment_link) | `payment_link` |
+| Al crear el link | `ERA-{id}-{ts}` (tu referencia) | `referencia_era` |
+| En el callback URL | Todos los params GET de Bold | `callback` |
+| En el webhook | `payment_id` del evento | `webhook_payment_id` |
+| En el webhook | `metadata.reference` del evento | `webhook_referencia` |
+
+### Función de búsqueda por cualquier referencia
+
+```php
+function buscarUsuarioIdPorReferencia(PDO $pdo, string $referencia): ?int {
+ $stmt = $pdo->prepare('
+ SELECT usuario_id
+ FROM usuarios_referencias_pago
+ WHERE referencia = ?
+ LIMIT 1
+ ');
+ $stmt->execute([trim($referencia)]);
+ $row = $stmt->fetchColumn();
+ return $row ? (int)$row : null;
+}
+```
+
+### Cascada de búsqueda en el webhook
+
+```php
+$usuarioId = null;
+
+// 1. Por referencia personalizada ERA-{id}-{ts}
+if ($referencia !== '') {
+ $usuarioId = buscarUsuarioIdPorReferencia($pdo, $referencia);
+}
+
+// 2. Por payment_id de la transacción
+if (!$usuarioId && $paymentId !== '') {
+ $usuarioId = buscarUsuarioIdPorReferencia($pdo, $paymentId);
+}
+
+// 3. Por email (último recurso, solo si hay un único pendiente)
+if (!$usuarioId && $payerEmail !== '') {
+ $stmt = $pdo->prepare('
+ SELECT id FROM usuarios
+ WHERE email = ? AND estado_pago = "pendiente"
+ ORDER BY fecha_registro DESC LIMIT 1
+ ');
+ $stmt->execute([$payerEmail]);
+ $row = $stmt->fetchColumn();
+ $usuarioId = $row ? (int)$row : null;
+}
+```
+
+---
+
+## 9. Idempotencia
+
+Bold puede reenviar la misma notificación varias veces (reintentos ante timeouts).
+Debes garantizar que procesar la misma notificación dos veces no cause efectos duplicados
+(doble correo, doble registro de pago, etc.).
+
+### Tabla `webhook_log`
+
+```sql
+CREATE TABLE IF NOT EXISTS `webhook_log` (
+ `id` INT PRIMARY KEY AUTO_INCREMENT,
+ `notification_id` VARCHAR(64) NOT NULL,
+ `tipo` VARCHAR(30) NOT NULL,
+ `payment_id` VARCHAR(64) DEFAULT NULL,
+ `referencia` VARCHAR(120) DEFAULT NULL,
+ `usuario_id` INT DEFAULT NULL,
+ `procesado` TINYINT(1) NOT NULL DEFAULT 0,
+ `recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY `uq_notification` (`notification_id`),
+ INDEX `idx_usuario` (`usuario_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+### Patrón de INSERT IGNORE
+
+```php
+// Intentar insertar. Si ya existe (UNIQUE conflict) → rowCount() = 0 → duplicado
+$ins = $pdo->prepare('
+ INSERT IGNORE INTO webhook_log (notification_id, tipo, payment_id, referencia)
+ VALUES (?, ?, ?, ?)
+');
+$ins->execute([$notificationId, $tipo, $paymentId, $referencia]);
+
+if ($ins->rowCount() === 0) {
+ // Notificación ya procesada anteriormente
+ exit;
+}
+
+// Continúa procesamiento...
+
+// Al finalizar, marcar como procesado
+$pdo->prepare('UPDATE webhook_log SET procesado=1, usuario_id=? WHERE notification_id=?')
+ ->execute([$usuarioId, $notificationId]);
+```
+
+---
+
+## 10. Esquema de base de datos
+
+Tablas mínimas necesarias:
+
+```sql
+-- Tabla principal de usuarios/registros
+CREATE TABLE `usuarios` (
+ `id` INT PRIMARY KEY AUTO_INCREMENT,
+ `email` VARCHAR(255) NOT NULL,
+ `token` VARCHAR(64) NOT NULL UNIQUE,
+ `bold_order_id` VARCHAR(80) DEFAULT NULL, -- LNK_xxx principal
+ `estado_pago` ENUM('pendiente','pagado') NOT NULL DEFAULT 'pendiente',
+ `fecha_registro` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Historial de todas las referencias de Bold asociadas al usuario
+CREATE TABLE `usuarios_referencias_pago` (
+ `id` INT PRIMARY KEY AUTO_INCREMENT,
+ `usuario_id` INT NOT NULL,
+ `referencia` VARCHAR(120) NOT NULL,
+ `tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida',
+ `origen` VARCHAR(40) NOT NULL DEFAULT 'sistema',
+ `creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY `uq_referencia` (`referencia`),
+ INDEX `idx_usuario` (`usuario_id`),
+ CONSTRAINT `fk_ref_usuario`
+ FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Log de webhooks para idempotencia
+CREATE TABLE `webhook_log` (
+ `id` INT PRIMARY KEY AUTO_INCREMENT,
+ `notification_id` VARCHAR(64) NOT NULL,
+ `tipo` VARCHAR(30) NOT NULL,
+ `payment_id` VARCHAR(64) DEFAULT NULL,
+ `referencia` VARCHAR(120) DEFAULT NULL,
+ `usuario_id` INT DEFAULT NULL,
+ `procesado` TINYINT(1) NOT NULL DEFAULT 0,
+ `recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY `uq_notification` (`notification_id`),
+ INDEX `idx_usuario` (`usuario_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+---
+
+## 11. Flujo completo de extremo a extremo
+
+```
+Usuario llena formulario
+ │
+ ▼
+[Tu backend] genera referencia personalizada:
+ ERA-{usuarioId}-{timestamp}
+ │
+ ▼
+POST → API Bold /online/link/v1
+ │
+ ▼ respuesta LNK_xxx + URL
+[Guardar en DB]:
+ - usuarios.bold_order_id = LNK_xxx
+ - referencias_pago: LNK_xxx (tipo: payment_link)
+ - referencias_pago: ERA-... (tipo: referencia_era)
+ - sesión: bold_order_id, usuario_id
+ │
+ ▼
+Redirigir usuario a URL Bold (checkout)
+ │
+ Usuario paga
+ │
+ ┌────┴────────────────────────────────┐
+ │ │
+ ▼ ▼
+callback_url (GET) webhook (POST) ← fuente de verdad
+ Bold redirige al usuario Bold notifica el evento
+ con params: bold-order-id, etc.
+ │ │
+ ▼ ▼
+Mostrar pantalla "procesando" 1. Responder 200 INMEDIATAMENTE
+No marcar como pagado aún 2. Verificar firma HMAC
+ 3. Idempotencia (INSERT IGNORE)
+ 4. Resolver usuario por referencias
+ 5. UPDATE estado_pago = 'pagado'
+ 6. Enviar correo de confirmación
+```
+
+---
+
+## 12. Errores frecuentes y soluciones
+
+### El webhook no llega
+
+- Verificar que la URL esté registrada en el panel de Bold.
+- La URL debe ser **pública** (no localhost). Usar [ngrok](https://ngrok.com) para pruebas locales:
+ ```bash
+ ngrok http 80
+ # Registrar la URL https://xxxx.ngrok.io/webhook.php en Bold
+ ```
+- Confirmar que el servidor responde HTTP 200 en < 2 s.
+- Revisar el log de webhooks en el panel de Bold para ver reintentos.
+
+### Firma inválida en modo test
+
+El `secretKey` en modo test es `""` (cadena vacía). Asegúrate de usar eso, no la clave real.
+
+```php
+$keyForHmac = ($boldMode === 'production') ? $secretKey : '';
+```
+
+### No se encuentra el usuario en el webhook
+
+El `payment_id` que llega en el webhook puede ser **diferente** al `LNK_xxx` creado con la API.
+Solución: usar la tabla de historial de referencias y la referencia personalizada `ERA-{id}-{ts}`.
+
+### Bold reintenta y se procesan pagos dobles
+
+Implementar idempotencia con `webhook_log` usando `INSERT IGNORE` sobre `notification_id`.
+
+### HTTP 400 al crear el link
+
+- Verificar que `total_amount` sea entero (no float).
+- `reference` no puede superar 60 caracteres ni contener caracteres especiales.
+- La `callback_url` debe ser una URL accesible públicamente.
+
+### Error de timeout en curl
+
+Bold tiene un timeout de respuesta. Si el servidor tarda más de 15 s, la llamada falla.
+Configurar `CURLOPT_TIMEOUT` en 15 y asegurar que la conexión a internet es estable.
+
+---
+
+## Referencias oficiales
+
+- Documentación Bold: [https://developers.bold.co](https://developers.bold.co)
+- Panel Bold: [https://dashboard.bold.co](https://dashboard.bold.co)
+- API de links: `POST https://integrations.api.bold.co/online/link/v1`
+- Consultar link: `GET https://integrations.api.bold.co/online/link/v1/{id}`
diff --git a/main.go b/main.go
index 2531e39..fb732bc 100755
--- a/main.go
+++ b/main.go
@@ -8,6 +8,7 @@ import (
"github.com/pyroscope-io/pyroscope/pkg/agent/profiler"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/migrations"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/routes"
)
@@ -47,6 +48,8 @@ func main() {
// Seed automático (idempotente) de módulos del sistema
migrations.SeedRenovaciones()
migrations.SeedIntegraciones()
+ // Auto-migrar query_history si no existe
+ app.Http.Database.DB.AutoMigrate(&models.QueryHistory{})
// Iniciar cron de vencimientos
services.IniciarCron()
defer services.DetenerCron()
diff --git a/migrations/migrate.go b/migrations/migrate.go
index 719ede6..e5f93f5 100755
--- a/migrations/migrate.go
+++ b/migrations/migrate.go
@@ -53,6 +53,8 @@ func Migrate() {
// Integraciones externas
&models.HostingerConfig{},
&models.CloudflareConfig{},
+ // Query Runner
+ &models.QueryHistory{},
// Pasarelas de pago
&models.BoldConfig{},
&models.BoldWebhookLog{},
diff --git a/pkg/models/query_history.go b/pkg/models/query_history.go
new file mode 100644
index 0000000..57a88b8
--- /dev/null
+++ b/pkg/models/query_history.go
@@ -0,0 +1,45 @@
+package models
+
+import (
+ "time"
+
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "gorm.io/gorm"
+)
+
+// QueryHistory almacena el historial de consultas ejecutadas por los usuarios.
+type QueryHistory struct {
+ gorm.Model
+ ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"`
+ ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
+ SQL string `json:"sql" gorm:"column:sql;type:text"`
+ Status string `json:"status" gorm:"column:status"` // ok | error
+ ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
+ RowsAffect int64 `json:"rows_affect" gorm:"column:rows_affect"`
+ DurationMs int64 `json:"duration_ms" gorm:"column:duration_ms"`
+ ExecutedAt time.Time `json:"executed_at" gorm:"column:executed_at;autoCreateTime"`
+}
+
+func (QueryHistory) TableName() string {
+ return "query_history"
+}
+
+// SaveQueryHistory guarda una entrada en el historial.
+func SaveQueryHistory(h QueryHistory) error {
+ return app.Http.Database.DB.Create(&h).Error
+}
+
+// GetQueryHistory devuelve el historial de una conexión con paginación.
+func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
+ var items []QueryHistory
+ var total int64
+ db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID)
+ db.Count(&total)
+ err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error
+ return items, total, err
+}
+
+// DeleteQueryHistory elimina todo el historial de una conexión.
+func DeleteQueryHistory(conxDbID uint) error {
+ return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error
+}
diff --git a/pkg/services/query_runner_service.go b/pkg/services/query_runner_service.go
new file mode 100644
index 0000000..6c53e5c
--- /dev/null
+++ b/pkg/services/query_runner_service.go
@@ -0,0 +1,298 @@
+package services
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ _ "github.com/go-sql-driver/mysql"
+ _ "github.com/jackc/pgx/v5/stdlib"
+ _ "github.com/mattn/go-sqlite3"
+ _ "github.com/microsoft/go-mssqldb"
+
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+)
+
+// QueryResult contiene el resultado de una consulta SQL.
+type QueryResult struct {
+ Columns []string `json:"columns"`
+ Rows []map[string]any `json:"rows"`
+ RowCount int `json:"row_count"`
+ AffectedRows int64 `json:"affected_rows"`
+ DurationMs int64 `json:"duration_ms"`
+ IsSelect bool `json:"is_select"`
+ Error string `json:"error,omitempty"`
+}
+
+// openDynamicDB abre una conexión a la base de datos indicada por ConxDb.
+func openDynamicDB(c models.ConxDb) (*sql.DB, error) {
+ driver := strings.ToLower(c.TipoDb.Nombre)
+ host := c.Servidor.IpServidor
+ port := c.Puerto
+ user := c.Usuario
+ pass := c.Password
+
+ var dsn string
+ var driverName string
+
+ switch {
+ case strings.Contains(driver, "postgres"):
+ driverName = "postgres"
+ dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable", host, port, user, pass)
+ case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
+ driverName = "mysql"
+ dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/", user, pass, host, port)
+ case strings.Contains(driver, "sqlite"):
+ driverName = "sqlite3"
+ dsn = host // para sqlite el host es la ruta del archivo
+ case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
+ driverName = "sqlserver"
+ dsn = fmt.Sprintf("sqlserver://%s:%s@%s:%s", user, pass, host, port)
+ default:
+ return nil, fmt.Errorf("driver no soportado: %s", driver)
+ }
+
+ db, err := sql.Open(driverName, dsn)
+ if err != nil {
+ return nil, err
+ }
+ db.SetConnMaxLifetime(30 * time.Second)
+ db.SetMaxOpenConns(2)
+ return db, nil
+}
+
+// ExecuteSQL ejecuta SQL arbitrario contra la conexión y devuelve QueryResult.
+// También guarda en query_history.
+func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult {
+ start := time.Now()
+
+ db, err := openDynamicDB(conx)
+ if err != nil {
+ saveHistory(conx.ID, sqlText, "error", err.Error(), 0, time.Since(start).Milliseconds())
+ return QueryResult{Error: err.Error()}
+ }
+ defer db.Close()
+
+ // Si se especifica una base de datos para seleccionar
+ if database != "" {
+ driver := strings.ToLower(conx.TipoDb.Nombre)
+ if strings.Contains(driver, "postgres") {
+ // En postgres se cambia con SET search_path o reconectando con dbname en DSN
+ db2, err2 := openDynamicDBWithName(conx, database)
+ if err2 == nil {
+ db.Close()
+ db = db2
+ }
+ } else {
+ if _, err2 := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err2 != nil {
+ saveHistory(conx.ID, sqlText, "error", err2.Error(), 0, time.Since(start).Milliseconds())
+ return QueryResult{Error: err2.Error()}
+ }
+ }
+ }
+
+ trimmed := strings.TrimSpace(sqlText)
+ isSelect := isSelectStatement(trimmed)
+
+ var result QueryResult
+ result.IsSelect = isSelect
+
+ if isSelect {
+ rows, err := db.Query(trimmed)
+ if err != nil {
+ elapsed := time.Since(start).Milliseconds()
+ saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed)
+ return QueryResult{Error: err.Error(), IsSelect: true}
+ }
+ defer rows.Close()
+
+ cols, _ := rows.Columns()
+ result.Columns = cols
+
+ for rows.Next() {
+ vals := make([]any, len(cols))
+ ptrs := make([]any, len(cols))
+ for i := range vals {
+ ptrs[i] = &vals[i]
+ }
+ rows.Scan(ptrs...)
+ row := make(map[string]any, len(cols))
+ for i, col := range cols {
+ v := vals[i]
+ if b, ok := v.([]byte); ok {
+ row[col] = string(b)
+ } else {
+ row[col] = v
+ }
+ }
+ result.Rows = append(result.Rows, row)
+ }
+ result.RowCount = len(result.Rows)
+ } else {
+ res, err := db.Exec(trimmed)
+ elapsed := time.Since(start).Milliseconds()
+ if err != nil {
+ saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed)
+ return QueryResult{Error: err.Error()}
+ }
+ affected, _ := res.RowsAffected()
+ result.AffectedRows = affected
+ result.DurationMs = elapsed
+ saveHistory(conx.ID, sqlText, "ok", "", affected, elapsed)
+ return result
+ }
+
+ result.DurationMs = time.Since(start).Milliseconds()
+ saveHistory(conx.ID, sqlText, "ok", "", int64(result.RowCount), result.DurationMs)
+ return result
+}
+
+// ListDatabases devuelve la lista de bases de datos del servidor.
+func ListDatabases(conx models.ConxDb) ([]string, error) {
+ db, err := openDynamicDB(conx)
+ if err != nil {
+ return nil, err
+ }
+ defer db.Close()
+
+ driver := strings.ToLower(conx.TipoDb.Nombre)
+ var query string
+ switch {
+ case strings.Contains(driver, "postgres"):
+ query = "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"
+ case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
+ query = "SHOW DATABASES"
+ case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
+ query = "SELECT name FROM sys.databases ORDER BY name"
+ default:
+ return []string{"main"}, nil
+ }
+
+ rows, err := db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var dbs []string
+ for rows.Next() {
+ var name string
+ rows.Scan(&name)
+ dbs = append(dbs, name)
+ }
+ return dbs, nil
+}
+
+// ListTables devuelve las tablas de una base de datos.
+func ListTables(conx models.ConxDb, database string) ([]string, error) {
+ driver := strings.ToLower(conx.TipoDb.Nombre)
+
+ var db *sql.DB
+ var err error
+
+ if strings.Contains(driver, "postgres") {
+ db, err = openDynamicDBWithName(conx, database)
+ } else {
+ db, err = openDynamicDB(conx)
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer db.Close()
+
+ var query string
+ switch {
+ case strings.Contains(driver, "postgres"):
+ query = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
+ case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
+ if _, err := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err != nil {
+ return nil, err
+ }
+ query = "SHOW TABLES"
+ case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
+ query = fmt.Sprintf("USE [%s]; SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", database)
+ default:
+ query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
+ }
+
+ rows, err := db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tables []string
+ for rows.Next() {
+ var name string
+ rows.Scan(&name)
+ tables = append(tables, name)
+ }
+ return tables, nil
+}
+
+// TestConnection verifica si la conexión es válida.
+func TestDBConnection(conx models.ConxDb) error {
+ db, err := openDynamicDB(conx)
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+ return db.Ping()
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+func openDynamicDBWithName(c models.ConxDb, dbName string) (*sql.DB, error) {
+ host := c.Servidor.IpServidor
+ port := c.Puerto
+ user := c.Usuario
+ pass := c.Password
+ driver := strings.ToLower(c.TipoDb.Nombre)
+
+ var dsn string
+ switch {
+ case strings.Contains(driver, "postgres"):
+ dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", host, port, user, pass, dbName)
+ db, err := sql.Open("postgres", dsn)
+ if err != nil {
+ return nil, err
+ }
+ db.SetConnMaxLifetime(30 * time.Second)
+ db.SetMaxOpenConns(2)
+ return db, nil
+ default:
+ return openDynamicDB(c)
+ }
+}
+
+func isSelectStatement(sql string) bool {
+ upper := strings.ToUpper(strings.TrimSpace(sql))
+ keywords := []string{"SELECT ", "SHOW ", "DESCRIBE ", "EXPLAIN ", "WITH ", "PRAGMA "}
+ for _, kw := range keywords {
+ if strings.HasPrefix(upper, kw) {
+ return true
+ }
+ }
+ return false
+}
+
+func quoteIdentifier(name, driver string) string {
+ d := strings.ToLower(driver)
+ if strings.Contains(d, "postgres") {
+ return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
+ }
+ return "`" + strings.ReplaceAll(name, "`", "``") + "`"
+}
+
+func saveHistory(conxID uint, sqlText, status, errMsg string, rows, durationMs int64) {
+ models.SaveQueryHistory(models.QueryHistory{
+ ConxDbID: conxID,
+ SQL: sqlText,
+ Status: status,
+ ErrorMsg: errMsg,
+ RowsAffect: rows,
+ DurationMs: durationMs,
+ ExecutedAt: time.Now(),
+ })
+}
diff --git a/resources/views/conx_db.html b/resources/views/conx_db.html
index ab90f9a..40b116f 100755
--- a/resources/views/conx_db.html
+++ b/resources/views/conx_db.html
@@ -68,6 +68,15 @@
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
+
+
+
+
diff --git a/resources/views/query_runner.html b/resources/views/query_runner.html
new file mode 100644
index 0000000..746eb3d
--- /dev/null
+++ b/resources/views/query_runner.html
@@ -0,0 +1,382 @@
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sin resultados. Ejecuta una consulta.
+
+
+
+
+ | # |
+
+ |
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+
+
+ Historial de consultas
+
+
+
Sin historial para esta conexión.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/rest/controllers/query_runner_controller.go b/rest/controllers/query_runner_controller.go
new file mode 100644
index 0000000..261eafa
--- /dev/null
+++ b/rest/controllers/query_runner_controller.go
@@ -0,0 +1,237 @@
+package controllers
+
+import (
+ "bytes"
+ "encoding/csv"
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/services"
+)
+
+// QueryRunnerPage renderiza la vista del editor SQL.
+// GET /app/query-runner?conx_db_id=1
+func QueryRunnerPage(c *fiber.Ctx) error {
+ conxIDStr := c.Query("conx_db_id", "")
+ var conx models.ConxDb
+ if conxIDStr != "" {
+ id, _ := strconv.ParseUint(conxIDStr, 10, 32)
+ app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id)
+ }
+
+ data := fiber.Map{
+ "user": c.Locals("user").(map[string]interface{}),
+ "modules": c.Locals("userModules"),
+ "conx_db_id": conxIDStr,
+ "conx": conx,
+ }
+ if err := c.Render("query_runner", data, "layouts/main"); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+ return nil
+}
+
+// GetConxDbList devuelve todas las conexiones DB disponibles (para el selector).
+func GetConxDbList(c *fiber.Ctx) error {
+ var items []models.ConxDb
+ app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&items)
+ return c.JSON(fiber.Map{"data": items})
+}
+
+// GetDatabases lista las bases de datos de una conexión.
+// GET /app/query-runner/databases?conx_db_id=1
+func GetDatabases(c *fiber.Ctx) error {
+ conx, err := loadConxDb(c.Query("conx_db_id", "0"))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ dbs, err := services.ListDatabases(conx)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"data": dbs})
+}
+
+// GetTables lista las tablas de una base de datos.
+// GET /app/query-runner/tables?conx_db_id=1&db=mydb
+func GetTables(c *fiber.Ctx) error {
+ conx, err := loadConxDb(c.Query("conx_db_id", "0"))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ dbName := c.Query("db", "")
+ tables, err := services.ListTables(conx, dbName)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"data": tables})
+}
+
+// RunQuery ejecuta una consulta SQL.
+// POST /app/query-runner/run
+// Body: { conx_db_id, database, sql }
+func RunQuery(c *fiber.Ctx) error {
+ var body struct {
+ ConxDbID uint `json:"conx_db_id"`
+ Database string `json:"database"`
+ SQL string `json:"sql"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
+ }
+ if strings.TrimSpace(body.SQL) == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La consulta está vacía"})
+ }
+
+ conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ result := services.ExecuteSQL(conx, body.Database, body.SQL)
+ return c.JSON(result)
+}
+
+// TestConnection verifica que la conexión funciona.
+// GET /app/query-runner/test?conx_db_id=1
+func TestConnection(c *fiber.Ctx) error {
+ conx, err := loadConxDb(c.Query("conx_db_id", "0"))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+ if err := services.TestDBConnection(conx); err != nil {
+ return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"ok": false, "error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"ok": true})
+}
+
+// GetHistory devuelve el historial de una conexión.
+// GET /app/query-runner/history?conx_db_id=1&page=1
+func GetHistory(c *fiber.Ctx) error {
+ conxIDStr := c.Query("conx_db_id", "0")
+ conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
+ page, _ := strconv.Atoi(c.Query("page", "1"))
+ if page < 1 {
+ page = 1
+ }
+ limit := 50
+ offset := (page - 1) * limit
+
+ items, total, err := models.GetQueryHistory(uint(conxID), limit, offset)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ totalPages := int(math.Ceil(float64(total) / float64(limit)))
+ return c.JSON(fiber.Map{
+ "data": items,
+ "total": total,
+ "totalPages": totalPages,
+ "page": page,
+ })
+}
+
+// ClearHistory borra el historial de una conexión.
+// DELETE /app/query-runner/history?conx_db_id=1
+func ClearHistory(c *fiber.Ctx) error {
+ conxIDStr := c.Query("conx_db_id", "0")
+ conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
+ if err := models.DeleteQueryHistory(uint(conxID)); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"ok": true})
+}
+
+// ExportCSV exporta el resultado de una consulta como CSV.
+// POST /app/query-runner/export/csv
+func ExportCSV(c *fiber.Ctx) error {
+ var body struct {
+ ConxDbID uint `json:"conx_db_id"`
+ Database string `json:"database"`
+ SQL string `json:"sql"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
+ }
+ conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ result := services.ExecuteSQL(conx, body.Database, body.SQL)
+ if result.Error != "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
+ }
+
+ var buf bytes.Buffer
+ w := csv.NewWriter(&buf)
+ w.Write(result.Columns)
+ for _, row := range result.Rows {
+ rec := make([]string, len(result.Columns))
+ for i, col := range result.Columns {
+ v := row[col]
+ if v == nil {
+ rec[i] = ""
+ } else {
+ rec[i] = fmt.Sprintf("%v", v)
+ }
+ }
+ w.Write(rec)
+ }
+ w.Flush()
+
+ filename := fmt.Sprintf("query_%s.csv", time.Now().Format("20060102_150405"))
+ c.Set("Content-Disposition", "attachment; filename="+filename)
+ c.Set("Content-Type", "text/csv; charset=utf-8")
+ return c.SendStream(bytes.NewReader(buf.Bytes()), buf.Len())
+}
+
+// ExportJSON exporta el resultado como JSON.
+// POST /app/query-runner/export/json
+func ExportJSON(c *fiber.Ctx) error {
+ var body struct {
+ ConxDbID uint `json:"conx_db_id"`
+ Database string `json:"database"`
+ SQL string `json:"sql"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
+ }
+ conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ result := services.ExecuteSQL(conx, body.Database, body.SQL)
+ if result.Error != "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
+ }
+
+ data, _ := json.MarshalIndent(result.Rows, "", " ")
+ filename := fmt.Sprintf("query_%s.json", time.Now().Format("20060102_150405"))
+ c.Set("Content-Disposition", "attachment; filename="+filename)
+ c.Set("Content-Type", "application/json; charset=utf-8")
+ return c.SendStream(bytes.NewReader(data), len(data))
+}
+
+// ── helper ───────────────────────────────────────────────────────────────────
+
+func loadConxDb(idStr string) (models.ConxDb, error) {
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil || id == 0 {
+ return models.ConxDb{}, fmt.Errorf("conx_db_id inválido")
+ }
+ var conx models.ConxDb
+ if err := app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id).Error; err != nil {
+ return models.ConxDb{}, fmt.Errorf("conexión no encontrada: %w", err)
+ }
+ return conx, nil
+}
diff --git a/rest/controllers/seed_controller.go b/rest/controllers/seed_controller.go
index 981da5f..1ed16f5 100644
--- a/rest/controllers/seed_controller.go
+++ b/rest/controllers/seed_controller.go
@@ -20,6 +20,14 @@ func RunSeed(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
+ if err := seedModulo("Conexiones", "Gestión de servidores y conexiones de base de datos", []seedEntry{
+ {"Conexiones DB", "Lista y configuración de conexiones de base de datos", "/app/conexion_db"},
+ {"Query Runner", "Editor SQL: ejecutar consultas, exportar y ver historial", "/app/query-runner"},
+ {"Conexiones SSH", "Gestión de conexiones SSH a servidores", "/app/conexion_ssh"},
+ }); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
if err := seedModulo("Renovaciones", "Gestión de contratos, clientes y notificaciones de vencimiento", []seedEntry{
{"Contratos", "Gestión de contratos y vencimientos", "/app/contratos"},
{"Clientes", "Clientes y datos de contacto", "/app/clientes"},
diff --git a/rest/routes/user.go b/rest/routes/user.go
index a362a94..aabd978 100755
--- a/rest/routes/user.go
+++ b/rest/routes/user.go
@@ -97,7 +97,17 @@ func UserRoutes(app fiber.Router) {
// ─── Seed manual (admin) ────────────────────────────────────────────
protected.Get("/run-seed", controllers.RunSeed)
-
+ // ─── Query Runner (editor SQL) ────────────────────────────────────────────
+ protected.Get("/query-runner", middlewares.MenuMiddleware, controllers.QueryRunnerPage)
+ protected.Get("/query-runner/connections", controllers.GetConxDbList)
+ protected.Get("/query-runner/databases", controllers.GetDatabases)
+ protected.Get("/query-runner/tables", controllers.GetTables)
+ protected.Get("/query-runner/test", controllers.TestConnection)
+ protected.Post("/query-runner/run", controllers.RunQuery)
+ protected.Get("/query-runner/history", controllers.GetHistory)
+ protected.Delete("/query-runner/history", controllers.ClearHistory)
+ protected.Post("/query-runner/export/csv", controllers.ExportCSV)
+ protected.Post("/query-runner/export/json", controllers.ExportJSON)
// ─── Hostinger API ────────────────────────────────────────────────
protected.Get("/hostinger", middlewares.MenuMiddleware, controllers.HostingerConfigPage)
protected.Post("/hostinger/config", controllers.SaveHostingerConfig)
diff --git a/template_out/plantilla1.html b/template_out/plantilla1.html
new file mode 100644
index 0000000..73919d0
--- /dev/null
+++ b/template_out/plantilla1.html
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+ Renovación de servicios — USITE
+
+
+
+
+
+
+
+
+
+
+
+
+ | |
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ ● Renovación
+ |
+
+
+
+
+
+ Renueva antes de que venza
+
+
+ Uno o más servicios están próximos a expirar. Actúa antes de la fecha límite para evitar interrupciones.
+
+
+ |
+
+
+
+
+ |
+
+
+
+ Hola {{.ClienteNombre}} — para garantizar la continuidad digital de {{.ClienteEmpresa}}, te recomendamos renovar antes de la fecha indicada.
+
+
+
+
+
+ |
+ Vence
+ {{.FechaVencimiento}}
+ |
+
+ Días restantes
+ {{.DiasRestantes}} días
+ |
+
+ Total a pagar
+ ${{.Total}}
+ |
+
+
+
+
+ Servicios incluidos
+
+
+
+ {{range .Servicios}}
+
+ |
+
+ {{.Nombre}}
+ |
+ {{.Moneda}} {{.Precio}} |
+
+ {{end}}
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+ Si ya realizaste el pago, ignora este mensaje. ¿Necesitas ayuda? Escríbenos directamente a través de nuestro canal de soporte.
+
+ |
+
+
+
+
+
+
+
+ |
+
+ © USITE — Soluciones de Software a Medida
+
+ Mensaje automático · u-site.app
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+