Files
Lizandro GuarnizoandClaude Opus 5 d7c266f111 feat(soporte): filtro con IA para los correos entrantes, y que un correo no pueda abrir dos tickets
Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el
correo y decide si es soporte o ruido —newsletters, notificaciones de
plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está
configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que
tener un ticket de más. Hay un campo de contexto del negocio para los casos
raros de cada uno.

Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe,
Precedence, Auto-Submitted) sin gastar una llamada de IA.

Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la
deduplicación era solo un SELECT previo, que no sirve si el flag de leído no
llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un
índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo
se trata como "ya estaba", no como error — sin ticket repetido y sin segunda
auto-respuesta.

Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en
vez de dejarlo solo en el log: es la señal de que van a volver a leerse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:36:01 -05:00

1659 lines
61 KiB
Go
Executable File

package migrations
import (
"fmt"
"log"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Session structure for managing sessions
type Session struct {
ID uint `gorm:"primaryKey"`
SessionID string `gorm:"unique;not null"`
Data string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
ExpireAt time.Time
}
// Migrate performs the database migrations
func Migrate() {
log.Println("Initiating migration...")
// Migrate the main application models.
// Un solo AutoMigrate(a, b, c...) se detiene en el primer modelo que falle:
// todo lo que viene después en la lista se queda sin tabla. Migrando uno por
// uno, un modelo roto no bloquea al resto (mismo fix que en main.go).
modelosPrincipales := []interface{}{
&models.Roles{},
&models.Users{},
&models.Modules{},
&models.Submodules{},
&models.Inicio{},
&models.TipoServidor{},
&models.TipoDb{},
&models.Servidor{},
&models.ConxDb{},
&models.ConxSsh{},
&models.ProvServidor{},
&models.QrVcard{},
&models.OssApi{},
&models.VcfVcard{},
&models.DlocalApi{},
// Módulo de Renovaciones
&models.Servicio{},
&models.Cliente{},
&models.PlantillaCorreo{},
&models.NotificacionRegla{},
&models.Contrato{},
&models.NotificacionLog{},
&models.SmtpConfig{},
// Integraciones externas
&models.HostingerConfig{},
&models.CloudflareConfig{},
// Query Runner
&models.QueryHistory{},
// Pasarelas de pago
&models.BoldConfig{},
&models.BoldWebhookLog{},
&models.BoldCallbackLog{},
&models.DlocalPaymentLog{},
&models.PaypalConfig{},
// Documentación por SaaS
&models.SaasProducto{},
&models.DocCategoria{},
&models.DocPagina{},
// Integraciones SaaS (dispatcher de pagos)
&models.SaasApiConfig{},
&models.SaasDispatchLog{},
&models.SaasWebhookInLog{},
// Documentos de clientes
&models.ClienteDocumento{},
// Telegram
&models.TelegramConfig{},
&models.TelegramLog{},
// Portal de Clientes
&models.PortalUser{},
&models.PortalAcceso{},
&models.Proyecto{},
&models.ProyectoFase{},
&models.ProyectoAvance{},
&models.ProyectoEntregable{},
&models.ProyectoDocumento{},
&models.ProyectoTicket{},
&models.TicketMensaje{},
&models.Factura{},
// Sistema de notificaciones por evento
&models.NotifEventoConfig{},
&models.SistemaNotificacion{},
&models.ServidorAlertaUmbral{},
// Submódulo Partner
&models.PartnerRecurso{},
&models.PartnerComunicado{},
// Integración VCard API Admin (Laravel)
&models.VcardApiConfig{},
// Integración Coolify
&models.CoolifyConfig{},
// Historial de métricas del agente
&models.ServidorMetricasHistory{},
// Monitor de disponibilidad de URLs
&models.UrlMonitor{},
&models.UrlMonitorLog{},
// Tablero de tareas
&models.Tarea{},
&models.TareaComentario{},
// Pagos externos: API de cobros para aplicaciones de terceros
&models.ServicioPagoExterno{},
&models.SolicitudPagoExterna{},
// Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA
&models.PlantillaDocumento{},
&models.Tarifa{},
&models.DocumentoGenerado{},
&models.Arquitectura{},
// Vinculación de Telegram para staff interno
&models.TelegramStaffToken{},
// uMind: chat con IA embebible por tenant (widget web + RAG)
&models.UmindTenant{},
&models.UmindAgente{},
&models.UmindDocumento{},
&models.UmindChunk{},
&models.UmindMensaje{},
&models.UmindHerramienta{},
&models.UmindCanal{},
&models.UmindConexion{},
&models.UmindEventoLog{},
&models.UmindPlan{},
&models.UmindUso{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
// Integraciones: OCR y transcripción de audio (servicios propios)
&models.OcrConfig{},
&models.WhisperAsrConfig{},
}
for _, m := range modelosPrincipales {
if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil {
log.Printf("[MIGRATE] Error migrando %T: %v", m, err)
}
}
// Open (or create) a SQLite database for sessions
db, err := gorm.Open(sqlite.Open("session.db"), &gorm.Config{})
if err != nil {
log.Fatalf("Error connecting to the database: %v", err)
return
}
// Migrate the Session structure
if err := db.AutoMigrate(&Session{}); err != nil {
log.Fatalf("Error migrating session database: %v", err)
return
}
fmt.Println("Session database created and table migrated successfully.")
// Insertar módulo y submódulos de Renovaciones
SeedRenovaciones()
// Insertar módulo y submódulos de Integraciones externas
SeedIntegraciones()
// Insertar submódulo "Pasarelas de Pago" en el módulo Integraciones
SeedPasarelas()
// Insertar submódulos de SaaS (Productos + Integraciones) en el módulo Integraciones
SeedSaas()
// Insertar submódulo "Statuspage" en el módulo Integraciones
SeedStatuspage()
// Crear plantillas y reglas mínimas si no existen (bienvenida, pago_recibido)
SeedPlantillasBase()
// Agregar submódulo "Telegram" al módulo Integraciones
SeedTelegram()
// Crear módulo y submódulos de Portal de Clientes
SeedPortalClientes()
// Agregar submódulo "Partner Recursos" al módulo Portal de Clientes
SeedPartnerRecursos()
// Crear módulo y submódulos de Automatización IA (cotizaciones, contratos, asistente)
SeedAutomatizacionIA()
log.Println("Migration Completed...")
}
// asignarSubmodulosSiFaltan agrega submódulos a un rol solo si el rol todavía no
// tiene NINGUNO de ellos.
//
// Antes los seeds hacían Append incondicional en cada arranque: si un admin le
// quitaba un módulo a un rol desde el panel, el siguiente despliegue se lo
// devolvía y la gestión de permisos era, en la práctica, imposible de sostener.
func asignarSubmodulosSiFaltan(rol *models.Roles, subs []models.Submodules) {
if rol == nil || len(subs) == 0 {
return
}
db := app.Http.Database.DB
var ids []uint
for _, s := range subs {
if s.ID > 0 {
ids = append(ids, s.ID)
}
}
if len(ids) == 0 {
return
}
var yaAsignados int64
db.Table("roles_submodules").
Where("roles_id = ? AND submodules_id IN ?", rol.ID, ids).
Count(&yaAsignados)
if yaAsignados > 0 {
// El rol ya conoce este grupo de submódulos: lo que tenga hoy es una
// decisión del administrador y no se toca.
return
}
if err := db.Model(rol).Association("Submodules").Append(&subs); err != nil {
log.Printf("[SEED] Error asignando submódulos al rol %s: %v", rol.Name, err)
}
}
// SeedStatuspage agrega el submódulo "Statuspage" (Atlassian) al módulo "Integraciones"
// y lo asigna a todos los roles. Es idempotente.
func SeedStatuspage() {
db := app.Http.Database.DB
// Obtener el módulo "Integraciones" (debe existir luego de SeedIntegraciones)
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
log.Printf("[SEED] Módulo 'Integraciones' no encontrado para SeedStatuspage: %v", err)
return
}
entries := []struct{ title, desc, url string }{
// La página real es la pública /status (rest/routes/publicas.go):
// /app/statuspage nunca existió y el ítem daba 404 al entrar.
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/status"},
}
// La URL vieja apuntaba a /app/statuspage, que no existe: se corrige el
// registro ya creado en vez de dejar un ítem de menú que da 404.
db.Model(&models.Submodules{}).Where("url = ?", "/app/statuspage").Update("url", "/status")
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Statuspage completado.")
}
// SeedPlantillasBase crea una plantilla mínima y la regla de pago_recibido si no existen.
// Es idempotente — no sobreescribe registros existentes.
func SeedPlantillasBase() {
db := app.Http.Database.DB
plantillasBase := []struct {
nombre string
asunto string
tipo string
cuerpo string
}{
{
nombre: "Pago recibido (base)",
asunto: "Pago recibido — gracias por tu pago",
tipo: "pago",
cuerpo: `<!DOCTYPE html><html><body style="font-family:sans-serif;color:#333;max-width:600px;margin:auto;padding:24px">
<h2 style="color:#5a7d2b">Pago recibido ✔</h2>
<p>Hola <strong>{{.ClienteNombre}}</strong>,</p>
<p>Hemos recibido y confirmado tu pago. Tu servicio ha sido renovado correctamente.</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0">
{{range .Servicios}}
<tr style="border-bottom:1px solid #eee">
<td style="padding:8px">{{.Nombre}}</td>
<td style="padding:8px;text-align:right">{{.Moneda}} {{.Precio}}</td>
<td style="padding:8px;text-align:right;color:#888">Vence: {{.FechaVenc}}</td>
</tr>
{{end}}
</table>
<p style="font-size:13px;color:#888">Si tienes alguna pregunta, responde a este correo.</p>
</body></html>`,
},
{
nombre: "Bienvenida (base)",
asunto: "Bienvenido — tu servicio está activo",
tipo: "bienvenida",
cuerpo: `<!DOCTYPE html><html><body style="font-family:sans-serif;color:#333;max-width:600px;margin:auto;padding:24px">
<h2 style="color:#5a7d2b">¡Bienvenido!</h2>
<p>Hola <strong>{{.ClienteNombre}}</strong>,</p>
<p>Tu servicio ha sido activado. A continuación el detalle:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0">
{{range .Servicios}}
<tr style="border-bottom:1px solid #eee">
<td style="padding:8px">{{.Nombre}}</td>
<td style="padding:8px;text-align:right">{{.Moneda}} {{.Precio}}</td>
<td style="padding:8px;text-align:right;color:#888">Vence: {{.FechaVenc}}</td>
</tr>
{{end}}
</table>
<p style="font-size:13px;color:#888">Gracias por elegirnos.</p>
</body></html>`,
},
}
for _, p := range plantillasBase {
var existing models.PlantillaCorreo
if err := db.Where("nombre = ?", p.nombre).First(&existing).Error; err != nil {
nueva := models.PlantillaCorreo{
Nombre: p.nombre,
Asunto: p.asunto,
Tipo: p.tipo,
CuerpoHTML: p.cuerpo,
}
if err := db.Create(&nueva).Error; err != nil {
log.Printf("[SEED] Error creando plantilla '%s': %v", p.nombre, err)
continue
}
log.Printf("[SEED] Plantilla '%s' creada (ID %d)", p.nombre, nueva.ID)
} else {
log.Printf("[SEED] Plantilla '%s' ya existe (ID %d)", existing.Nombre, existing.ID)
}
}
// Crear regla pago_recibido si no existe
var reglaExistente models.NotificacionRegla
if err := db.Where("tipo_evento = ?", "pago_recibido").First(&reglaExistente).Error; err != nil {
// Buscar la plantilla de pago
var plantilla models.PlantillaCorreo
if err2 := db.Where("tipo = ?", "pago").First(&plantilla).Error; err2 != nil {
log.Printf("[SEED] No se encontró plantilla de tipo 'pago' para crear regla pago_recibido")
return
}
regla := models.NotificacionRegla{
Nombre: "Confirmación de pago",
TipoEvento: "pago_recibido",
PlantillaID: plantilla.ID,
Activo: true,
AplicaA: "todos",
PasarelaEnlace: "ninguna",
}
if err := db.Create(&regla).Error; err != nil {
log.Printf("[SEED] Error creando regla pago_recibido: %v", err)
} else {
log.Printf("[SEED] Regla 'pago_recibido' creada (ID %d) con plantilla ID %d", regla.ID, plantilla.ID)
}
} else {
log.Printf("[SEED] Regla 'pago_recibido' ya existe (ID %d)", reglaExistente.ID)
}
log.Println("[SEED] SeedPlantillasBase completado.")
}
// MigrateRenovaciones crea/actualiza las tablas del módulo de Renovaciones.
// Es idempotente: GORM AutoMigrate solo añade columnas/tablas nuevas, nunca las borra.
func MigrateRenovaciones() {
db := app.Http.Database.DB
if err := db.AutoMigrate(
&models.Servicio{},
&models.Cliente{},
&models.PlantillaCorreo{},
&models.NotificacionRegla{},
&models.Contrato{}, // incluye columnas enlace_pago y enlace_pago_link_id
&models.NotificacionLog{},
&models.SmtpConfig{},
); err != nil {
log.Printf("[MIGRATE] Error en MigrateRenovaciones: %v", err)
} else {
log.Println("[MIGRATE] Tablas de Renovaciones OK")
}
}
// SeedRenovaciones inserta el módulo "Renovaciones" y sus submódulos si no existen,
// y los asigna a todos los roles presentes en la BD.
func SeedRenovaciones() {
db := app.Http.Database.DB
// 1. Crear/obtener módulo
var modulo models.Modules
result := db.Where("title = ?", "Renovaciones").First(&modulo)
if result.Error != nil {
modulo = models.Modules{
Title: "Renovaciones",
Description: "Gestión de contratos, clientes y notificaciones de vencimiento",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Renovaciones: %v", err)
return
}
log.Printf("[SEED] Módulo 'Renovaciones' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Renovaciones' ya existe (ID %d)", modulo.ID)
}
// 2. Definir submódulos
entries := []struct{ title, desc, url string }{
{"Contratos", "Gestión de contratos y vencimientos", "/app/contratos"},
{"Clientes", "Clientes y datos de contacto", "/app/clientes"},
{"Servicios", "Catálogo de servicios", "/app/servicios"},
{"Plantillas Correo", "Editor de plantillas de correo", "/app/plantillas-correo"},
{"Reglas de Aviso", "Reglas de notificación automática", "/app/reglas-notificacion"},
{"Historial Notif.", "Historial de correos enviados", "/app/historial-notificaciones"},
{"Config. SMTP", "Configuración del servidor de correo", "/app/smtp-config"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
// Asegurar que el submódulo apunte al módulo correcto (Renovaciones)
if sub.ModuleId != modulo.ID {
if err := db.Model(&sub).Update("module_id", modulo.ID).Error; err != nil {
log.Printf("[SEED] Error actualizando module_id de '%s': %v", sub.Title, err)
} else {
log.Printf("[SEED] Submódulo '%s' reasignado a módulo Renovaciones", sub.Title)
}
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
}
insertados = append(insertados, sub)
}
// 3. Asignar solo al rol Administrador
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Renovaciones asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de Renovaciones completado.")
}
// SeedAutomatizacionIA inserta el módulo "Automatización IA" y sus submódulos
// (Asistente, Plantillas de Documento, Tarifas) si no existen, y los asigna al
// rol Administrador. Es idempotente.
func SeedAutomatizacionIA() {
db := app.Http.Database.DB
// 1. Crear/obtener módulo
var modulo models.Modules
result := db.Where("title = ?", "Automatización IA").First(&modulo)
if result.Error != nil {
modulo = models.Modules{
Title: "Automatización IA",
Description: "Cotizaciones, contratos, actas y cuentas de cobro generados por IA",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Automatización IA: %v", err)
return
}
log.Printf("[SEED] Módulo 'Automatización IA' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Automatización IA' ya existe (ID %d)", modulo.ID)
}
// 2. Definir submódulos
entries := []struct{ title, desc, url string }{
{"Asistente", "Chat propio: mismo motor y tools que el bot de Telegram", "/app/asistente"},
{"Chats autorizados", "Quién puede hablarle al bot de Telegram y ejecutar acciones", "/app/agente/chats-autorizados"},
{"Plantillas de Documento", "Fuente de verdad de cotización, contrato, acta y cuenta de cobro", "/app/automatizacion/plantillas"},
{"Tarifas", "Valor por hora, licencias, VMs y márgenes usados al cotizar", "/app/automatizacion/tarifas"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
if sub.ModuleId != modulo.ID {
if err := db.Model(&sub).Update("module_id", modulo.ID).Error; err != nil {
log.Printf("[SEED] Error actualizando module_id de '%s': %v", sub.Title, err)
} else {
log.Printf("[SEED] Submódulo '%s' reasignado a módulo Automatización IA", sub.Title)
}
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
}
insertados = append(insertados, sub)
}
// 3. Asignar solo al rol Administrador
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Automatización IA asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de Automatización IA completado.")
}
// SeedIntegraciones crea el módulo "Integraciones" con los submódulos
// de Hostinger y Cloudflare, y los asigna a todos los roles existentes.
// Es idempotente: si el registro ya existe (por URL) lo reutiliza sin duplicar.
func SeedIntegraciones() {
db := app.Http.Database.DB
// 1. Crear/obtener módulo
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Integraciones",
Description: "Conexión con servicios externos: Hostinger, Cloudflare y más",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Integraciones: %v", err)
return
}
log.Printf("[SEED] Módulo 'Integraciones' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Integraciones' ya existe (ID %d)", modulo.ID)
}
// 2. Definir submódulos
entries := []struct{ title, desc, url string }{
{"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"},
{"Cloudflare", "Gestión de zonas, DNS, SSL y firewall en Cloudflare", "/app/cloudflare"},
{"VCard API", "Integración con el sistema VCard externo (Laravel + Sanctum): usuarios, membresías, vcards, pagos y más", "/app/vcard-api"},
{"OCR", "Extracción de texto de imágenes (comprobantes, capturas) vía servicio OCR propio", "/app/ocr"},
{"Whisper ASR", "Transcripción de audio vía servicio propio de reconocimiento de voz (self-hosted)", "/app/whisper-asr"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
if sub.ModuleId != modulo.ID {
if err := db.Model(&sub).Update("module_id", modulo.ID).Error; err != nil {
log.Printf("[SEED] Error actualizando module_id de '%s': %v", sub.Title, err)
} else {
log.Printf("[SEED] Submódulo '%s' reasignado a módulo Integraciones", sub.Title)
}
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
}
insertados = append(insertados, sub)
}
// 3. Asignar a todos los roles existentes
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Integraciones completado.")
}
// SeedPasarelas agrega el submódulo "Pasarelas de Pago" al módulo "Integraciones"
// y lo asigna a todos los roles. Es idempotente.
func SeedPasarelas() {
db := app.Http.Database.DB
// Obtener el módulo "Integraciones" (debe existir luego de SeedIntegraciones)
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
log.Printf("[SEED] Módulo 'Integraciones' no encontrado para SeedPasarelas: %v", err)
return
}
entries := []struct{ title, desc, url string }{
{"Pasarelas de Pago", "Configuración de Bold, dLocal y otras pasarelas", "/app/pasarelas-pago"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Pasarelas completado.")
}
// SeedServidores crea el módulo "Servidores" con los submódulos de gestión
// de servidores, conexiones SSH/DB, tipos y el Query Runner. Es idempotente.
func SeedServidores() {
db := app.Http.Database.DB
// 1. Crear/obtener módulo
var modulo models.Modules
if err := db.Where("title = ?", "Servidores").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Servidores",
Description: "Gestión de servidores, conexiones de base de datos y Query Runner",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Servidores: %v", err)
return
}
log.Printf("[SEED] Módulo 'Servidores' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Servidores' ya existe (ID %d)", modulo.ID)
}
// 2. Definir submódulos
entries := []struct{ title, desc, url string }{
{"Servidores", "Lista y gestión de servidores registrados", "/app/servidor"},
{"Conexión DB", "Conexiones de base de datos a servidores", "/app/conexion_db"},
{"Conexión SSH", "Conexiones SSH a servidores", "/app/conexion_ssh"},
{"Tipo DB", "Catálogo de tipos de base de datos", "/app/tipo_db"},
{"Tipo Servidor", "Catálogo de tipos de servidor", "/app/tipo_servidor"},
{"Proveedor Servidor", "Catálogo de proveedores de servidor", "/app/prov_servidor"},
{"Query Runner", "Editor SQL con historial y exportación", "/app/query-runner"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
log.Printf("[SEED] Submódulo '%s' reasignado a módulo Servidores", sub.Title)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
}
insertados = append(insertados, sub)
}
// 3. Asignar a todos los roles existentes
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Servidores completado.")
}
// SeedAdministracion crea el módulo "Administración" con sus submódulos
// (Roles, Módulos, Submódulos, Usuarios) y los asigna a todos los roles. Es idempotente.
func SeedAdministracion() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Administración",
Description: "Gestión de usuarios, roles, módulos y permisos del sistema",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Administración: %v", err)
return
}
log.Printf("[SEED] Módulo 'Administración' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Administración' ya existe (ID %d)", modulo.ID)
}
entries := []struct{ title, desc, url string }{
{"Roles", "Gestión de roles y permisos", "/app/roles"},
{"Módulos", "Gestión de módulos del menú lateral", "/app/modules"},
{"Submódulos", "Gestión de submódulos y URLs", "/app/submodules"},
{"Usuarios", "Gestión de usuarios del sistema", "/app/users"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
log.Printf("[SEED] Submódulo '%s' reasignado a módulo Administración", sub.Title)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
}
insertados = append(insertados, sub)
}
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Administración completado.")
}
// SeedSaas agrega los submódulos de gestión de productos SaaS e integraciones
// de pago al módulo "Integraciones" ya existente. Es idempotente.
func SeedSaas() {
db := app.Http.Database.DB
// Reutilizar el módulo "Integraciones" (creado por SeedIntegraciones)
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
log.Printf("[SEED] Módulo 'Integraciones' no encontrado para SeedSaas: %v", err)
return
}
entries := []struct{ title, desc, url string }{
{"Productos SaaS", "Gestión de productos SaaS y su documentación asociada", "/app/saas"},
{"Integraciones SaaS", "Callbacks de pago hacia APIs externas y logs de despacho", "/app/saas-api"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de SaaS completado.")
}
// MigratePortal ejecuta AutoMigrate para los modelos del portal de clientes.
func MigratePortal() {
db := app.Http.Database.DB
if err := db.AutoMigrate(
&models.Proyecto{},
&models.ProyectoFase{},
&models.ProyectoAvance{},
&models.ProyectoEntregable{},
&models.ProyectoDocumento{},
&models.ProyectoTicket{},
&models.TicketMensaje{},
&models.PortalUser{},
&models.PortalAcceso{},
&models.TelegramPortalToken{},
&models.Factura{},
); err != nil {
log.Printf("[MIGRATE] Error en MigratePortal: %v", err)
} else {
log.Println("[MIGRATE] MigratePortal completado.")
}
}
// MigrateLanding crea/actualiza la tabla de sesiones del Landing Generator.
func MigrateLanding() {
db := app.Http.Database.DB
if err := db.AutoMigrate(&models.LandingSession{}, &models.AiConfig{}); err != nil {
log.Printf("[MIGRATE] Error en MigrateLanding: %v", err)
} else {
log.Println("[MIGRATE] Tablas landing_sessions y ai_configs OK")
}
}
// SeedTelegram agrega el submódulo de Telegram al módulo "Integraciones". Es idempotente.
func SeedTelegram() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
log.Printf("[SEED] Módulo 'Integraciones' no encontrado para SeedTelegram: %v", err)
return
}
url := "/app/telegram"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Telegram",
Description: "Configuración de bots de Telegram para notificaciones",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err2 := db.Create(&sub).Error; err2 != nil {
log.Printf("[SEED] Error creando submódulo Telegram: %v", err2)
return
}
log.Printf("[SEED] Submódulo 'Telegram' creado (ID %d)", sub.ID)
} else {
log.Printf("[SEED] Submódulo 'Telegram' ya existe (ID %d)", sub.ID)
}
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
log.Println("[SEED] Seed de Telegram completado.")
}
// SeedPortalClientes agrega el módulo "Portal de Clientes" con sus submódulos. Es idempotente.
func SeedPortalClientes() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Portal de Clientes").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Portal de Clientes",
Description: "Gestión del portal de acceso para clientes",
ModifiedAt: time.Now(),
}
if err2 := db.Create(&modulo).Error; err2 != nil {
log.Printf("[SEED] Error creando módulo 'Portal de Clientes': %v", err2)
return
}
log.Printf("[SEED] Módulo 'Portal de Clientes' creado (ID %d)", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Portal de Clientes' ya existe (ID %d)", modulo.ID)
}
entries := []struct{ title, desc, url string }{
{"Proyectos", "Gestión de proyectos y roadmap para clientes", "/app/proyectos"},
{"Facturas", "Gestión de facturas y cobros del portal", "/app/facturas"},
{"Portal Usuarios", "Gestión de usuarios del portal de clientes", "/app/portal-usuarios"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err2 := db.Create(&sub).Error; err2 != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err2)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
// Solo se asigna al rol Administrador: los demás roles pueden estar
// restringidos a propósito y no deben recibir módulos nuevos por su cuenta
// — hay que habilitarlos manualmente desde /app/roles si corresponde.
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado: %v", err)
return
}
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Println("[SEED] Seed de Portal de Clientes completado.")
}
// SeedNotifDefaults inserta configuraciones de notificación por defecto (idempotente).
// Solo crea registros que no existen; no sobreescribe cambios del admin.
func SeedNotifDefaults() {
db := app.Http.Database.DB
defaults := []models.NotifEventoConfig{
// Tickets
{Evento: "ticket_nuevo", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Admin recibe cuando el cliente abre un ticket"},
{Evento: "ticket_respuesta_cliente", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Admin recibe cuando el cliente responde un ticket"},
{Evento: "ticket_respuesta_admin", Destinatario: "portal_user", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Portal user recibe cuando el admin responde"},
{Evento: "factura_subida", Destinatario: "portal_user", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Portal user recibe cuando se sube una factura"},
// Servidores
{Evento: "servidor_caido", Destinatario: "admin", CanalEmail: false, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando un servidor deja de reportar"},
{Evento: "servidor_vence_pronto", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando un VPS está próximo a vencer"},
{Evento: "servidor_recurso_alto", Destinatario: "admin", CanalEmail: false, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando CPU/RAM/Disco supera el umbral"},
// Tareas
{Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"},
{Evento: "tarea_estado", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando cambia el estado de una tarea"},
{Evento: "tarea_comentario", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando hay un nuevo comentario en una tarea"},
}
created := 0
for _, d := range defaults {
var existing models.NotifEventoConfig
if err := db.Where("evento = ? AND destinatario = ?", d.Evento, d.Destinatario).First(&existing).Error; err != nil {
db.Create(&d)
created++
}
}
log.Printf("[SEED] SeedNotifDefaults: %d nuevas configuraciones creadas.", created)
// Corrección puntual (una sola vez): tarea_asignada/admin se sembró originalmente
// con canal_telegram=false, dejando la notificación muerta desde su creación.
// Si nadie tocó el registro desde entonces, se activa por defecto.
var taRow models.NotifEventoConfig
if err := db.Where("evento = ? AND destinatario = ?", "tarea_asignada", "admin").First(&taRow).Error; err == nil {
if !taRow.CanalTelegram && taRow.UpdatedAt.Equal(taRow.CreatedAt) {
db.Model(&taRow).Update("canal_telegram", true)
log.Println("[SEED] tarea_asignada/admin: canal_telegram activado (corrección de default)")
}
}
}
// SeedShield es un stub idempotente para la configuración de Shield.
func SeedShield() {
log.Println("[SEED] SeedShield: sin entradas por defecto definidas aún.")
}
// SeedPartnerRecursos agrega el submódulo "Partner Recursos" al módulo "Portal de Clientes". Es idempotente.
func SeedPartnerRecursos() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Portal de Clientes").First(&modulo).Error; err != nil {
log.Printf("[SEED] Módulo 'Portal de Clientes' no encontrado para SeedPartnerRecursos: %v", err)
return
}
url := "/app/partner-recursos"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Partner Recursos",
Description: "Documentación y comunicados exclusivos para partners",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err2 := db.Create(&sub).Error; err2 != nil {
log.Printf("[SEED] Error creando submódulo Partner Recursos: %v", err2)
return
}
log.Printf("[SEED] Submódulo 'Partner Recursos' creado (ID %d)", sub.ID)
} else {
log.Printf("[SEED] Submódulo 'Partner Recursos' ya existe (ID %d)", sub.ID)
}
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Partner Recursos' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Partner Recursos completado.")
}
// SeedContabilidadMenu crea el módulo "Contabilidad" y sus submódulos en el menú lateral.
func SeedContabilidadMenu() {
db := app.Http.Database.DB
var modulo models.Modules
result := db.Where("title = ?", "Contabilidad").First(&modulo)
if result.Error != nil {
modulo = models.Modules{
Title: "Contabilidad",
Description: "Gestión contable: ingresos, egresos, cuentas por cobrar/pagar",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Contabilidad: %v", err)
return
}
log.Printf("[SEED] Módulo 'Contabilidad' creado con ID %d", modulo.ID)
} else {
log.Printf("[SEED] Módulo 'Contabilidad' ya existe (ID %d)", modulo.ID)
}
entries := []struct{ title, desc, url string }{
{"Dashboard", "Resumen mensual", "/app/contabilidad"},
{"Transacciones", "Ingresos y egresos", "/app/contabilidad/transacciones"},
{"Categorías", "Plan de cuentas", "/app/contabilidad/cuentas"},
{"Entidades", "Clientes y proveedores", "/app/contabilidad/entidades"},
{"Cuentas por cobrar", "Pendientes de cobro", "/app/contabilidad/cuentas-cobro"},
{"Cuentas por pagar", "Pendientes de pago", "/app/contabilidad/cuentas-pagar"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title, Description: e.desc, Url: e.url,
ModuleId: modulo.ID, ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
insertados = append(insertados, sub)
}
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rolAdmin, insertados)
log.Printf("[SEED] Submódulos de Contabilidad asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de Contabilidad completado.")
}
// SeedWebSms crea el submódulo de WebSMS bajo "Integraciones".
func SeedWebSms() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Integraciones' no encontrado, se creará")
modulo = models.Modules{
Title: "Integraciones", Description: "Conexión con servicios externos",
ModifiedAt: time.Now(),
}
db.Create(&modulo)
}
url := "/app/websms"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "WebSMS", Description: "Notificaciones SMS vía LabsMobile",
Url: url, ModuleId: modulo.ID, ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo WebSMS: %v", err)
return
}
log.Printf("[SEED] Submódulo 'WebSMS' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rolAdmin models.Roles
if err := db.Where("name = ?", "Administrador").First(&rolAdmin).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rolAdmin, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'WebSMS' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de WebSMS completado.")
}
// SeedUrlMonitor agrega el submódulo "Monitor de URLs" al módulo "Servidores". Es idempotente.
func SeedUrlMonitor() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Servidores").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Servidores' no encontrado, se omite SeedUrlMonitor")
return
}
url := "/app/url-monitor"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Monitor de URLs",
Description: "Monitoreo de disponibilidad y latencia de sitios y APIs",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'Monitor de URLs': %v", err)
return
}
log.Printf("[SEED] Submódulo 'Monitor de URLs' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Monitor de URLs' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Monitor de URLs completado.")
}
// SeedTareas agrega el submódulo "Tareas" al módulo "Administración" solo para el rol Administrador. Es idempotente.
func SeedTareas() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedTareas")
return
}
url := "/app/tareas"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Tareas",
Description: "Tablero kanban de tareas internas",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'Tareas': %v", err)
return
}
log.Printf("[SEED] Submódulo 'Tareas' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Tareas' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Tareas completado.")
}
// SeedPagosExternos registra el submódulo del panel de administración de
// servicios de pago externos (emisión de tokens de API para terceros).
func SeedPagosExternos() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedPagosExternos")
return
}
url := "/app/pagos-externos"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "Pagos externos",
Description: "Tokens de API para que apps de terceros pidan cobros por Bold/dLocal/PayPal",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'Pagos externos': %v", err)
return
}
log.Printf("[SEED] Submódulo 'Pagos externos' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'Pagos externos' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de Pagos externos completado.")
}
// SeedUmind registra el submódulo del panel de administración de uMind
// (tenants, base de conocimiento y conversaciones del widget embebible).
func SeedUmind() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Automatización IA").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Automatización IA' no encontrado, se omite SeedUmind")
return
}
// El panel viejo (Alpine, /app/umind) sigue existiendo y respondiendo, pero
// el menú apunta al SPA. La URL pasó por /app/umind → /orchestrator →
// /studio: se busca por cualquiera de las tres y se migra el mismo
// registro, en vez de dejar submódulos duplicados en el menú.
url := "/studio"
var sub models.Submodules
if err := db.Where("url IN ?", []string{url, "/orchestrator", "/app/umind"}).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "uMind Studio",
Description: "Creación y gestión de agentes de IA: conocimiento, canales, herramientas y consumo",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'uMind': %v", err)
return
}
log.Printf("[SEED] Submódulo 'uMind' creado")
} else {
updates := map[string]interface{}{}
if sub.ModuleId != modulo.ID {
updates["module_id"] = modulo.ID
}
if sub.Url != url {
updates["url"] = url
}
if len(updates) > 0 {
db.Model(&sub).Updates(updates)
}
}
// Planes de uMind: límite de agentes y precios por consumo. Solo staff.
var subPlanes models.Submodules
if err := db.Where("url = ?", "/app/umind-planes").First(&subPlanes).Error; err != nil {
subPlanes = models.Submodules{
Title: "Planes uMind",
Description: "Límite de agentes y precios por consumo (IA, OCR, transcripción, mensualidad)",
Url: "/app/umind-planes",
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&subPlanes).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'Planes uMind': %v", err)
} else {
log.Printf("[SEED] Submódulo 'Planes uMind' creado")
}
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
subs := []models.Submodules{sub}
if subPlanes.ID > 0 {
subs = append(subs, subPlanes)
}
asignarSubmodulosSiFaltan(&rol, subs)
log.Printf("[SEED] Submódulos de uMind asignados al rol 'Administrador'")
}
log.Println("[SEED] Seed de uMind completado.")
}
// SeedApiKeys registra el submódulo del panel de administración de API Keys
// (credenciales scoped para /api/v2, alternativa al ADMIN_API_KEY maestro).
func SeedApiKeys() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil {
log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedApiKeys")
return
}
url := "/app/api-keys"
var sub models.Submodules
if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: "API Keys",
Description: "Credenciales scoped para /api/v2 (token + IP + alcance), alternativa a la llave maestra",
Url: url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo 'API Keys': %v", err)
return
}
log.Printf("[SEED] Submódulo 'API Keys' creado")
} else if sub.ModuleId != modulo.ID {
db.Model(&sub).Update("module_id", modulo.ID)
}
var rol models.Roles
if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
} else {
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'API Keys' asignado al rol 'Administrador'")
}
log.Println("[SEED] Seed de API Keys completado.")
}
// MigrarUmindAgentes crea un agente "Principal" por cada UmindTenant que
// todavía no tenga ninguno, heredando lo que antes vivía en el tenant
// (site_key, config de IA, tono, mensaje de bienvenida, color) y mueve los
// datos que ya tenía (documentos, chunks, tools, canales, conexiones,
// mensajes) de tenant_id a agente_id. Idempotente: un tenant que ya tiene
// al menos un agente se salta — así corre sola en cada arranque sin
// duplicar nada, mismo criterio que los Seed* de este archivo.
func MigrarUmindAgentes() {
db := app.Http.Database.DB
var tenants []models.UmindTenant
if err := db.Find(&tenants).Error; err != nil {
log.Printf("[MIGRACION] Error leyendo umind_tenants: %v", err)
return
}
tablasConAgenteID := []string{
"umind_documentos", "umind_chunks", "umind_herramientas",
"umind_canales", "umind_conexiones", "umind_mensajes",
}
for _, t := range tenants {
var yaTieneAgente int64
if err := db.Model(&models.UmindAgente{}).Where("tenant_id = ?", t.ID).Count(&yaTieneAgente).Error; err != nil {
log.Printf("[MIGRACION] Error contando agentes del tenant %d: %v", t.ID, err)
continue
}
if yaTieneAgente > 0 {
continue
}
// Las columnas viejas (site_key, ai_config_id, tono,
// mensaje_bienvenida, color) ya no están en el struct Go UmindTenant,
// pero AutoMigrate nunca las borró de la tabla — se leen directo.
var viejo struct {
SiteKey string
AiConfigID *uint
Tono string
MensajeBienvenida string
Color string
}
if err := db.Table("umind_tenants").
Select("site_key, ai_config_id, tono, mensaje_bienvenida, color").
Where("id = ?", t.ID).Scan(&viejo).Error; err != nil {
log.Printf("[MIGRACION] Error leyendo datos viejos del tenant %d: %v", t.ID, err)
continue
}
agente := models.UmindAgente{
TenantID: t.ID,
Nombre: "Principal",
SiteKey: viejo.SiteKey,
AiConfigID: viejo.AiConfigID,
Tono: viejo.Tono,
MensajeBienvenida: viejo.MensajeBienvenida,
Color: viejo.Color,
Activo: true,
}
if agente.SiteKey == "" {
key, err := models.GenerarSiteKey()
if err != nil {
log.Printf("[MIGRACION] Error generando site_key para el agente del tenant %d: %v", t.ID, err)
continue
}
agente.SiteKey = key
}
if agente.Color == "" {
agente.Color = "#8eb02f"
}
if err := db.Create(&agente).Error; err != nil {
log.Printf("[MIGRACION] Error creando agente Principal del tenant %d: %v", t.ID, err)
continue
}
for _, tabla := range tablasConAgenteID {
sql := fmt.Sprintf("UPDATE %s SET agente_id = ? WHERE tenant_id = ? AND (agente_id IS NULL OR agente_id = 0)", tabla)
if err := db.Exec(sql, agente.ID, t.ID).Error; err != nil {
log.Printf("[MIGRACION] Error moviendo %s del tenant %d al agente %d: %v", tabla, t.ID, agente.ID, err)
}
}
log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID)
}
}
// LiberarColumnasHuerfanasUmind quita el NOT NULL de las columnas que dejaron
// de mapearse cuando uMind pasó a multi-agente.
//
// GORM agrega columnas pero nunca las borra ni les cambia las restricciones.
// Al sacar SiteKey de UmindTenant y renombrar TenantID→AgenteID en seis
// tablas, las columnas viejas quedaron en la base CON su NOT NULL original.
// El INSERT nuevo ya no las incluye, así que Postgres lo rechaza:
//
// null value in column "site_key" violates not-null constraint (23502)
//
// Sin esto no se puede crear un tenant, ni un documento, ni un chunk, ni
// guardar un mensaje: es decir, uMind queda inutilizable después de
// desplegar el refactor.
//
// No se hace DROP COLUMN a propósito: los datos viejos siguen ahí por si hay
// que reconciliar algo. Solo se libera la restricción.
// ColumnasHuerfanasUmind es la lista que recorre LiberarColumnasHuerfanasUmind.
// Está afuera de la función para poder verificar en un test que cada nombre de
// tabla coincide con el TableName() real del modelo: un typo acá haría que la
// migración no encuentre la columna y no haga nada, en silencio.
var ColumnasHuerfanasUmind = []struct{ Tabla, Columna string }{
{"umind_tenants", "site_key"},
{"umind_documentos", "tenant_id"},
{"umind_chunks", "tenant_id"},
{"umind_mensajes", "tenant_id"},
{"umind_herramientas", "tenant_id"},
{"umind_canales", "tenant_id"},
{"umind_conexiones", "tenant_id"},
}
func LiberarColumnasHuerfanasUmind() {
db := app.Http.Database.DB
for _, h := range ColumnasHuerfanasUmind {
// Se consulta information_schema en vez de intentar el ALTER a ciegas:
// en una instalación nueva la columna no existe y el error sería ruido
// en cada arranque.
var nullable string
err := db.Raw(`
SELECT is_nullable FROM information_schema.columns
WHERE table_name = ? AND column_name = ?
`, h.Tabla, h.Columna).Scan(&nullable).Error
if err != nil || nullable == "" || nullable == "YES" {
continue
}
sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP NOT NULL", h.Tabla, h.Columna)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[MIGRACION] no se pudo liberar %s.%s: %v", h.Tabla, h.Columna, err)
continue
}
log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna)
}
}
// VerificarUrlsDeSubmodulos avisa en el arranque de los ítems del menú cuya
// URL no corresponde a ninguna ruta registrada.
//
// Un submódulo es una URL escrita a mano (en un seed o desde /app/submodules).
// Si no coincide con una ruta real, el ítem igual aparece en el menú y se le
// puede asignar permiso a un rol — y recién al hacer clic aparece un 404, sin
// nada que explique por qué. Esto lo convierte en una línea de log al
// arrancar, en vez de un misterio en producción.
//
// Solo informa: no borra ni modifica nada, porque una URL "rota" puede ser una
// ruta servida por otro lado.
func VerificarUrlsDeSubmodulos(rutasGET map[string]bool) {
var subs []models.Submodules
if err := app.Http.Database.DB.Find(&subs).Error; err != nil {
log.Printf("[MENU] no se pudieron revisar las URLs de los submódulos: %v", err)
return
}
rotos := 0
for _, s := range subs {
url := strings.TrimSpace(s.Url)
if url == "" {
log.Printf("[MENU] el submódulo %q (ID %d) no tiene URL", s.Title, s.ID)
rotos++
continue
}
if !rutasGET[url] {
log.Printf("[MENU] el submódulo %q (ID %d) apunta a %q, que no es ninguna ruta: va a dar 404", s.Title, s.ID, url)
rotos++
}
}
if rotos == 0 {
log.Printf("[MENU] %d submódulos revisados, todas las URLs resuelven", len(subs))
} else {
log.Printf("[MENU] %d de %d submódulos apuntan a una URL inexistente (ver líneas anteriores)", rotos, len(subs))
}
}
// SeedDocumentacion registra los submódulos de Documentación.
//
// Existían las rutas y las vistas pero nunca el seed, así que había que crear
// el ítem del menú a mano desde /app/submodules — y una URL mal tipeada ahí es
// indistinguible de un permiso mal asignado: en los dos casos se ve un 404.
func SeedDocumentacion() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Documentación").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Documentación",
Description: "Páginas y categorías de documentación interna",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Documentación: %v", err)
return
}
}
entries := []struct{ title, desc, url string }{
{"Doc: Páginas", "Páginas de documentación", "/app/doc/paginas"},
{"Doc: Categorías", "Categorías de documentación", "/app/doc/categorias"},
}
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
}
}
}
// IndicesUnicosMessageID crea índices únicos parciales sobre el Message-Id de
// los correos ya procesados. Es lo que hace que un mismo correo no pueda abrir
// dos tickets pase lo que pase: si el buzón vuelve a entregarlo, si el flag de
// leído no se guardó, o si dos instancias de la app lo leen a la vez, la base
// rechaza el segundo.
//
// Parcial (WHERE message_id <> ”) porque los tickets del portal no tienen
// Message-Id y todos comparten la cadena vacía.
func IndicesUnicosMessageID() {
db := app.Http.Database.DB
indices := []struct{ nombre, tabla string }{
{"idx_tickets_message_id_unico", "proyecto_tickets"},
{"idx_ticket_mensajes_message_id_unico", "ticket_mensajes"},
}
for _, ix := range indices {
var existe bool
if err := db.Raw(
`SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = ?)`, ix.nombre,
).Scan(&existe).Error; err != nil || existe {
continue
}
// Si ya hay duplicados de antes, el índice no se puede crear. Se avisa y
// se sigue: el chequeo previo en código igual filtra la mayoría.
sql := fmt.Sprintf(
`CREATE UNIQUE INDEX %s ON %s (message_id) WHERE message_id <> '' AND deleted_at IS NULL`,
ix.nombre, ix.tabla)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[MIGRATE] No se pudo crear %s (¿hay correos duplicados de antes?): %v", ix.nombre, err)
continue
}
log.Printf("[MIGRATE] Índice %s creado.", ix.nombre)
}
}