feat: Query Runner — editor SQL con historial, exportar CSV/JSON, árbol de tablas

This commit is contained in:
Lizandro Guarnizo
2026-05-01 11:14:09 -05:00
parent 357bdc7397
commit 1731855df8
11 changed files with 1781 additions and 1 deletions
+637
View File
@@ -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}`
+3
View File
@@ -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()
+2
View File
@@ -53,6 +53,8 @@ func Migrate() {
// Integraciones externas
&models.HostingerConfig{},
&models.CloudflareConfig{},
// Query Runner
&models.QueryHistory{},
// Pasarelas de pago
&models.BoldConfig{},
&models.BoldWebhookLog{},
+45
View File
@@ -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
}
+298
View File
@@ -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(),
})
}
+9
View File
@@ -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" />
</svg>
</button>
<!-- Botón Query Runner -->
<a :href="'/app/query-runner?conx_db_id=' + data.ID" title="Abrir editor SQL"
class="inline-flex items-center justify-center w-5 h-5 text-blue-500 hover:text-blue-700 transition">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
</a>
</div>
</td>
</tr>
+382
View File
@@ -0,0 +1,382 @@
<div x-data="queryRunner" class="h-full bg-white rounded-lg shadow flex flex-col">
<!-- Overlay carga -->
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-70 flex justify-center items-center z-50">
<img src="../img/loading.gif" class="w-14 h-14" />
</div>
<div class="flex h-full" style="min-height:calc(100vh - 80px)">
<!-- ══════════════════ SIDEBAR IZQUIERDO (árbol) ══════════════════ -->
<aside class="w-64 shrink-0 border-r flex flex-col bg-gray-50">
<!-- Selector de conexión -->
<div class="p-3 border-b">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Conexión DB</label>
<select x-model="selectedConxId" @change="onConxChange()"
class="w-full border rounded px-2 py-1.5 text-xs">
<option value="">— Selecciona —</option>
<template x-for="c in conexiones" :key="c.ID">
<option :value="c.ID"
x-text="(c.servidor?.nombre || c.servidor_id) + ' · ' + (c.tipo_db?.nombre || '') + ' :' + c.puerto">
</option>
</template>
</select>
<!-- Test conexión -->
<button @click="testConn()" x-show="selectedConxId" :disabled="testLoading"
class="mt-1.5 w-full text-xs py-1 rounded border border-gray-300 hover:bg-white transition flex items-center justify-center gap-1">
<span x-show="!testLoading">⚡ Probar conexión</span>
<span x-show="testLoading">Probando…</span>
</button>
<p x-show="testMsg" class="text-xs mt-1 font-medium"
:class="testOk ? 'text-green-600' : 'text-red-500'" x-text="testMsg"></p>
</div>
<!-- Selector de base de datos -->
<div class="p-3 border-b" x-show="selectedConxId">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Base de datos</label>
<select x-model="selectedDb" @change="loadTables()"
class="w-full border rounded px-2 py-1.5 text-xs">
<option value="">— Selecciona DB —</option>
<template x-for="db in databases" :key="db">
<option :value="db" x-text="db"></option>
</template>
</select>
</div>
<!-- Árbol de tablas -->
<div class="flex-1 overflow-y-auto p-2" x-show="tables.length > 0">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Tablas</p>
<template x-for="t in tables" :key="t">
<button @click="insertTable(t)"
class="w-full text-left text-xs px-2 py-1 rounded hover:bg-[#e9f0cf] text-gray-700 truncate flex items-center gap-1">
<svg class="w-3 h-3 shrink-0 text-[#8eb02f]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/>
<line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/>
</svg>
<span x-text="t"></span>
</button>
</template>
</div>
<!-- Mensaje sin conexión -->
<div x-show="!selectedConxId" class="flex-1 flex items-center justify-center p-4">
<p class="text-xs text-gray-400 text-center">Selecciona una conexión para explorar bases de datos y tablas.</p>
</div>
</aside>
<!-- ══════════════════ ÁREA PRINCIPAL ══════════════════ -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Barra de herramientas -->
<div class="flex items-center gap-2 px-4 py-2.5 border-b bg-white">
<button @click="runQuery()"
:disabled="!selectedConxId || loadingQuery"
class="flex items-center gap-1.5 px-4 py-1.5 text-white text-xs font-semibold rounded-lg transition disabled:opacity-40"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span x-text="loadingQuery ? 'Ejecutando…' : 'Ejecutar'"></span>
<kbd class="ml-1 text-[10px] opacity-70">Ctrl+↵</kbd>
</button>
<button @click="clearEditor()" class="px-3 py-1.5 text-xs border rounded hover:bg-gray-50 transition">
Limpiar
</button>
<div class="flex-1"></div>
<!-- Exportar -->
<div class="flex items-center gap-1" x-show="results.length > 0">
<span class="text-xs text-gray-400" x-text="results.length + ' filas'"></span>
<button @click="exportData('csv')"
class="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded hover:bg-green-50 transition">
↓ CSV
</button>
<button @click="exportData('json')"
class="px-3 py-1.5 text-xs border border-blue-300 text-blue-700 rounded hover:bg-blue-50 transition">
↓ JSON
</button>
</div>
<!-- Tab historial -->
<button @click="activeTab = activeTab === 'history' ? 'results' : 'history'"
class="px-3 py-1.5 text-xs border rounded transition"
:class="activeTab === 'history' ? 'bg-[#8eb02f] text-white border-[#8eb02f]' : 'hover:bg-gray-50'">
Historial
</button>
</div>
<!-- Editor SQL -->
<div class="px-4 pt-3 pb-0">
<div class="border rounded-lg overflow-hidden bg-gray-900">
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-800 border-b border-gray-700">
<span class="text-xs text-gray-400">SQL Editor</span>
<div class="flex-1"></div>
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
</div>
<textarea id="sql-editor" x-model="sqlText"
@keydown.ctrl.enter.prevent="runQuery()"
@keydown.meta.enter.prevent="runQuery()"
placeholder="-- Escribe tu consulta SQL aquí&#10;-- Ctrl+Enter para ejecutar&#10;SELECT * FROM tabla LIMIT 100;"
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
style="height:180px; tab-size:2;"></textarea>
</div>
</div>
<!-- Panel de resultados / historial -->
<div class="flex-1 overflow-hidden flex flex-col px-4 pt-3 pb-4">
<!-- Status bar -->
<div x-show="statusMsg" class="mb-2 px-3 py-1.5 rounded text-xs font-medium"
:class="statusOk ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-600 border border-red-200'"
x-text="statusMsg"></div>
<!-- ── TAB: Resultados ── -->
<div x-show="activeTab === 'results'" class="flex-1 overflow-auto border rounded-lg">
<div x-show="results.length === 0 && !loadingQuery" class="flex flex-col items-center justify-center h-40 text-gray-400 text-sm gap-2">
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 5.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"/>
</svg>
<span>Sin resultados. Ejecuta una consulta.</span>
</div>
<table x-show="results.length > 0" class="table-auto w-full text-xs">
<thead class="sticky top-0 bg-gray-50 z-10">
<tr>
<th class="py-2 px-3 border-b text-left font-semibold text-gray-500 w-10">#</th>
<template x-for="col in columns" :key="col">
<th class="py-2 px-3 border-b text-left font-semibold text-gray-600 whitespace-nowrap" x-text="col"></th>
</template>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="(row, idx) in results" :key="idx">
<tr class="hover:bg-gray-50">
<td class="py-2 px-3 text-gray-400 select-none" x-text="idx+1"></td>
<template x-for="col in columns" :key="col">
<td class="py-2 px-3 font-mono max-w-xs truncate"
:title="nullStr(row[col])"
x-text="nullStr(row[col])"></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
<!-- ── TAB: Historial ── -->
<div x-show="activeTab === 'history'" class="flex-1 overflow-auto border rounded-lg">
<div class="flex items-center justify-between px-3 py-2 border-b bg-gray-50 sticky top-0">
<span class="text-xs font-semibold text-gray-500">Historial de consultas</span>
<button @click="clearHistory()" x-show="history.length > 0"
class="text-xs text-red-500 hover:underline">Borrar historial</button>
</div>
<div x-show="history.length === 0" class="py-10 text-center text-xs text-gray-400">Sin historial para esta conexión.</div>
<template x-for="(h, i) in history" :key="h.ID">
<div class="px-3 py-2.5 border-b hover:bg-gray-50 group cursor-pointer"
@click="sqlText = h.sql">
<div class="flex items-start justify-between gap-2">
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap break-all flex-1 max-h-12 overflow-hidden" x-text="h.sql"></pre>
<div class="shrink-0 flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium"
:class="h.status === 'ok' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'"
x-text="h.status"></span>
<span class="text-[10px] text-gray-400" x-text="h.duration_ms + 'ms'"></span>
</div>
</div>
<p x-show="h.error_msg" class="text-[10px] text-red-400 mt-0.5 truncate" x-text="h.error_msg"></p>
<p class="text-[10px] text-gray-400 mt-0.5" x-text="formatDate(h.executed_at)"></p>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div x-show="toast.show" x-cloak x-transition
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
:class="toast.type === 'error' ? 'bg-red-500' : 'bg-[#8eb02f]'"
x-text="toast.msg"></div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('queryRunner', () => ({
loading: false,
loadingQuery: false,
testLoading: false,
selectedConxId: '',
selectedDb: '',
conexiones: [],
databases: [],
tables: [],
sqlText: '',
columns: [],
results: [],
history: [],
activeTab: 'results',
statusMsg: '',
statusOk: true,
testMsg: '',
testOk: true,
toast: { show: false, msg: '', type: 'ok' },
async init() {
// Pre-cargar ID desde URL si viene de conx_db
const params = new URLSearchParams(window.location.search);
const preId = params.get('conx_db_id');
const res = await axios.get('/app/query-runner/connections');
this.conexiones = res.data.data || [];
if (preId) {
this.selectedConxId = parseInt(preId);
await this.onConxChange();
}
},
async onConxChange() {
this.databases = [];
this.tables = [];
this.selectedDb = '';
this.results = [];
this.columns = [];
this.statusMsg = '';
this.history = [];
if (!this.selectedConxId) return;
try {
const res = await axios.get('/app/query-runner/databases?conx_db_id=' + this.selectedConxId);
this.databases = res.data.data || [];
} catch (e) {
this.showToast(e.response?.data?.error || 'Error al cargar DBs', 'error');
}
await this.loadHistory();
},
async loadTables() {
this.tables = [];
if (!this.selectedDb) return;
try {
const res = await axios.get('/app/query-runner/tables?conx_db_id=' + this.selectedConxId + '&db=' + encodeURIComponent(this.selectedDb));
this.tables = res.data.data || [];
} catch (e) {
this.showToast(e.response?.data?.error || 'Error al cargar tablas', 'error');
}
},
async testConn() {
this.testLoading = true;
this.testMsg = '';
try {
await axios.get('/app/query-runner/test?conx_db_id=' + this.selectedConxId);
this.testOk = true;
this.testMsg = '✓ Conexión exitosa';
} catch (e) {
this.testOk = false;
this.testMsg = '✗ ' + (e.response?.data?.error || 'Error de conexión');
}
this.testLoading = false;
setTimeout(() => this.testMsg = '', 4000);
},
async runQuery() {
if (!this.selectedConxId || !this.sqlText.trim()) return;
this.loadingQuery = true;
this.statusMsg = '';
this.results = [];
this.columns = [];
try {
const res = await axios.post('/app/query-runner/run', {
conx_db_id: parseInt(this.selectedConxId),
database: this.selectedDb,
sql: this.sqlText
});
const data = res.data;
if (data.error) {
this.statusOk = false;
this.statusMsg = '✗ ' + data.error;
} else if (data.is_select) {
this.columns = data.columns || [];
this.results = data.rows || [];
this.statusOk = true;
this.statusMsg = `${this.results.length} fila(s) — ${data.duration_ms}ms`;
} else {
this.statusOk = true;
this.statusMsg = `${data.affected_rows} fila(s) afectadas — ${data.duration_ms}ms`;
}
} catch (e) {
this.statusOk = false;
this.statusMsg = '✗ ' + (e.response?.data?.error || 'Error al ejecutar');
}
this.loadingQuery = false;
await this.loadHistory();
},
async loadHistory() {
if (!this.selectedConxId) return;
try {
const res = await axios.get('/app/query-runner/history?conx_db_id=' + this.selectedConxId);
this.history = res.data.data || [];
} catch (_) {}
},
async clearHistory() {
if (!confirm('¿Borrar todo el historial de esta conexión?')) return;
try {
await axios.delete('/app/query-runner/history?conx_db_id=' + this.selectedConxId);
this.history = [];
this.showToast('Historial borrado');
} catch (e) {
this.showToast('Error al borrar historial', 'error');
}
},
async exportData(fmt) {
if (!this.sqlText.trim()) return;
try {
const res = await axios.post('/app/query-runner/export/' + fmt, {
conx_db_id: parseInt(this.selectedConxId),
database: this.selectedDb,
sql: this.sqlText
}, { responseType: 'blob' });
const mime = fmt === 'csv' ? 'text/csv' : 'application/json';
const blob = new Blob([res.data], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'query_result.' + fmt;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
this.showToast('Error al exportar', 'error');
}
},
insertTable(name) {
const sel = `SELECT * FROM ${name} LIMIT 100;`;
this.sqlText = this.sqlText ? this.sqlText + '\n' + sel : sel;
document.getElementById('sql-editor')?.focus();
},
clearEditor() { this.sqlText = ''; this.results = []; this.columns = []; this.statusMsg = ''; },
nullStr(v) { return v === null || v === undefined ? 'NULL' : String(v); },
formatDate(d) {
if (!d) return '';
return new Date(d).toLocaleString();
},
showToast(msg, type = 'ok') {
this.toast = { show: true, msg, type };
setTimeout(() => this.toast.show = false, 3500);
}
}));
});
</script>
+237
View File
@@ -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
}
+8
View File
@@ -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"},
+11 -1
View File
@@ -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)
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Renovación de servicios — USITE</title>
</head>
<body style="margin:0; padding:0; background:#060a0f;">
<!-- Email wrapper — compatible con clientes de correo -->
<div style="margin:0; padding:32px 16px; background:#060a0f; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px; margin:0 auto;">
<tr><td>
<!-- Card principal -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#0a0f16; border-radius:20px; overflow:hidden; border:1px solid #1a2840; box-shadow:0 24px 64px rgba(0,0,0,0.6);">
<!-- Accent bar superior -->
<tr>
<td style="height:3px; background:linear-gradient(90deg,#16a34a 0%,#22c55e 50%,#4ade80 100%); font-size:0; line-height:0;">&nbsp;</td>
</tr>
<!-- Header -->
<tr>
<td style="padding:36px 40px 32px; background:#0a0f16; border-bottom:1px solid #141f2e;">
<!-- Logo + badge row -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<img src="https://u-site.app/img/USITE_PNG_8.webp" alt="USITE" height="28"
style="display:block; height:28px; width:auto;" />
</td>
<td align="right">
<span style="display:inline-block; background:#0d2218; border:1px solid #166534; border-radius:100px; padding:4px 12px; font-size:10px; letter-spacing:0.1em; color:#4ade80; text-transform:uppercase; font-weight:600;">● Renovación</span>
</td>
</tr>
</table>
<!-- Título -->
<h1 style="margin:28px 0 8px; font-size:24px; font-weight:600; color:#f1f5f9; letter-spacing:-0.03em; line-height:1.3;">
Renueva antes de que venza
</h1>
<p style="margin:0; font-size:13.5px; color:#7a93a8; line-height:1.65;">
Uno o más servicios están próximos a expirar. Actúa antes de la fecha límite para evitar interrupciones.
</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:32px 40px;">
<!-- Saludo -->
<p style="margin:0 0 28px; font-size:14px; color:#8faabb; line-height:1.75;">
Hola <span style="color:#e2e8f0; font-weight:500;">{{.ClienteNombre}}</span> — para garantizar la continuidad digital de <span style="color:#e2e8f0; font-weight:500;">{{.ClienteEmpresa}}</span>, te recomendamos renovar antes de la fecha indicada.
</p>
<!-- Info strip — 3 métricas -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:12px; overflow:hidden; border:1px solid #141f2e; margin-bottom:28px;">
<tr>
<td width="33%" style="background:#0d1420; padding:18px 16px; border-right:1px solid #141f2e; vertical-align:top;">
<p style="margin:0 0 5px; font-size:9.5px; letter-spacing:0.1em; text-transform:uppercase; color:#334155;">Vence</p>
<p style="margin:0; font-size:13px; font-weight:600; color:#e2e8f0;">{{.FechaVencimiento}}</p>
</td>
<td width="33%" style="background:#0d1420; padding:18px 16px; border-right:1px solid #141f2e; vertical-align:top;">
<p style="margin:0 0 5px; font-size:9.5px; letter-spacing:0.1em; text-transform:uppercase; color:#334155;">Días restantes</p>
<p style="margin:0; font-size:13px; font-weight:600; color:#fbbf24;">{{.DiasRestantes}} días</p>
</td>
<td width="34%" style="background:#0d1420; padding:18px 16px; vertical-align:top;">
<p style="margin:0 0 5px; font-size:9.5px; letter-spacing:0.1em; text-transform:uppercase; color:#334155;">Total a pagar</p>
<p style="margin:0; font-size:16px; font-weight:700; color:#4ade80;">${{.Total}}</p>
</td>
</tr>
</table>
<!-- Label servicios -->
<p style="margin:0 0 10px; font-size:10px; letter-spacing:0.12em; text-transform:uppercase; color:#2d3f52; font-weight:600;">Servicios incluidos</p>
<!-- Lista de servicios -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="border:1px solid #141f2e; border-radius:12px; overflow:hidden; margin-bottom:32px;">
{{range .Servicios}}
<tr>
<td style="padding:13px 18px; border-bottom:1px solid #0f1a27; font-size:13px; color:#94a3b8;">
<span style="display:inline-block; width:6px; height:6px; border-radius:50%; background:#1e3a2f; border:1px solid #166534; margin-right:10px; vertical-align:middle;"></span>
{{.Nombre}}
</td>
<td style="padding:13px 18px; border-bottom:1px solid #0f1a27; font-size:13px; color:#4ade80; text-align:right; white-space:nowrap; font-weight:500;">{{.Moneda}} {{.Precio}}</td>
</tr>
{{end}}
</table>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<a href="#"
style="display:inline-block; background:#22c55e; color:#052e16; padding:15px 40px; border-radius:12px; font-size:14px; font-weight:700; text-decoration:none; letter-spacing:0.02em;">
Renovar servicios &nbsp;
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer note -->
<tr>
<td style="padding:20px 40px; border-top:1px solid #0f1a27;">
<p style="margin:0; font-size:12px; color:#5a7a90; line-height:1.7;">
Si ya realizaste el pago, ignora este mensaje. ¿Necesitas ayuda? Escríbenos directamente a través de <a href="https://vcard.u-site.app/usite" style="color:#22c55e; text-decoration:none;">nuestro canal de soporte</a>.
</p>
</td>
</tr>
<!-- Footer brand -->
<tr>
<td style="padding:18px 40px; background:#060a0f; border-top:1px solid #0f1a27;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<p style="margin:0; font-size:11px; color:#1e2d3d; letter-spacing:0.04em;">
© USITE — Soluciones de Software a Medida
</p>
<p style="margin:4px 0 0; font-size:10px; color:#162030;">Mensaje automático · <a href="https://u-site.app" style="color:#1e3a2f; text-decoration:none;">u-site.app</a></p>
</td>
<td align="right">
<img src="https://u-site.app/img/USITE_PNG_8.webp" alt="USITE" height="18"
style="display:block; height:18px; width:auto; opacity:0.25;" />
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- /Card principal -->
</td></tr>
</table>
</div>
</body>
</html>