package models import ( "time" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "github.com/sujit-baniya/fiber-boilerplate/app" "gorm.io/gorm" ) // UmindTenant representa un sitio/cliente de uMind — dueño de los dominios // permitidos y el nombre del negocio que se muestra al visitante. Un tenant // puede tener varios UmindAgente independientes (cada uno con su propia // config de IA, base de conocimiento, tools y canales); lo que antes vivía // acá (SiteKey, AiConfigID, Tono, MensajeBienvenida, Color) se movió a // UmindAgente — ver pkg/models/umind_agente.go y migrations.MigrarUmindAgentes. type UmindTenant struct { gorm.Model Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"` DominiosPermitidos string `json:"dominios_permitidos" gorm:"column:dominios_permitidos;type:text"` // coma-separado, ej: u-site.app,www.u-site.app Activo bool `json:"activo" gorm:"column:activo;default:true"` CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"` // ClienteID es lo que le permite a un PortalUser llegar a sus tenants // (ver GetClienteIDsForPortalUser). Puntero y sin not null: los tenants // creados antes de que existiera el portal quedan sin cliente hasta que // el staff los asigne, y el ALTER TABLE no falla sobre datos existentes. ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"` PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"` } func (UmindTenant) TableName() string { return "umind_tenants" } // GenerarSiteKey crea un identificador público único para el widget de un // agente. No se hashea (a diferencia de un token de API) porque no es un // secreto: viaja en el HTML público del sitio del cliente. func GenerarSiteKey() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("no se pudo generar la site_key: %w", err) } return "umk_" + hex.EncodeToString(b), nil } func CreateUmindTenant(t *UmindTenant) error { return app.Http.Database.DB.Create(t).Error } func GetAllUmindTenants(limit, offset int) ([]UmindTenant, int64, error) { var items []UmindTenant var total int64 db := app.Http.Database.DB.Model(&UmindTenant{}) if err := db.Count(&total).Error; err != nil { return nil, 0, err } if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { return nil, 0, err } return items, total, nil } // GetUmindTenantsByClientes lista los tenants que pertenecen a alguno de los // clientes dados — es la base del alcance del portal. Lista vacía devuelve // vacío sin consultar (fail-closed: un portal user sin clientes no ve nada). func GetUmindTenantsByClientes(clienteIDs []uint) ([]UmindTenant, error) { if len(clienteIDs) == 0 { return []UmindTenant{}, nil } var items []UmindTenant err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("id DESC").Find(&items).Error return items, err } func GetUmindTenantByID(id uint) (*UmindTenant, error) { var t UmindTenant if err := app.Http.Database.DB.First(&t, id).Error; err != nil { return nil, err } return &t, nil } func UpdateUmindTenant(id uint, updates map[string]interface{}) error { return app.Http.Database.DB.Model(&UmindTenant{}).Where("id = ?", id).Updates(updates).Error } func DeleteUmindTenant(id uint) error { return app.Http.Database.DB.Delete(&UmindTenant{}, id).Error } // DominioPermitido valida el host de un Origin/Referer contra la lista // configurada. Admite dominio exacto o comodín "*.dominio.com" para // subdominios. Lista vacía = no permite nada (fail-closed): un tenant recién // creado sin dominios configurados no debe poder ser usado desde ningún sitio. func (t *UmindTenant) DominioPermitido(host string) bool { host = strings.ToLower(strings.TrimSpace(host)) if host == "" { return false } lista := strings.TrimSpace(t.DominiosPermitidos) if lista == "" { return false } for _, entrada := range strings.Split(lista, ",") { entrada = strings.ToLower(strings.TrimSpace(entrada)) if entrada == "" { continue } if strings.HasPrefix(entrada, "*.") { sufijo := entrada[1:] // ".dominio.com" if strings.HasSuffix(host, sufijo) { return true } continue } if entrada == host { return true } } return false } // ─── Documentos y chunks de conocimiento ──────────────────────────────────── // UmindDocumento es una fuente de conocimiento de un agente: una URL // crawleada o un archivo subido. Se trocea en UmindChunk para la búsqueda // por similitud. type UmindDocumento struct { gorm.Model AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"` Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, o el nombre del archivo Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error Error string `json:"error" gorm:"column:error;type:text"` TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"` } func (UmindDocumento) TableName() string { return "umind_documentos" } func CreateUmindDocumento(d *UmindDocumento) error { return app.Http.Database.DB.Create(d).Error } func GetUmindDocumentosByAgente(agenteID uint) ([]UmindDocumento, error) { var items []UmindDocumento err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Order("id DESC").Find(&items).Error return items, err } func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) { var d UmindDocumento if err := app.Http.Database.DB.First(&d, id).Error; err != nil { return nil, err } return &d, nil } func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error { return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(map[string]interface{}{ "estado": estado, "error": errMsg, "total_chunks": totalChunks, }).Error } func DeleteUmindDocumento(id uint) error { if err := app.Http.Database.DB.Where("documento_id = ?", id).Delete(&UmindChunk{}).Error; err != nil { return err } return app.Http.Database.DB.Delete(&UmindDocumento{}, id).Error } // UmindChunk es un fragmento de texto con su embedding, listo para búsqueda // por similitud. Sin pgvector por ahora: el embedding se guarda como JSON de // []float32 y la similitud se calcula en memoria (suficiente para el volumen // de un piloto de un solo agente; si el volumen crece, se migra a pgvector // sin cambiar la interfaz de búsqueda). type UmindChunk struct { gorm.Model AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"` DocumentoID uint `json:"documento_id" gorm:"column:documento_id;index;not null"` Contenido string `json:"contenido" gorm:"column:contenido;type:text;not null"` EmbeddingJSON string `json:"-" gorm:"column:embedding_json;type:text"` } func (UmindChunk) TableName() string { return "umind_chunks" } // EmbeddingToJSON / EmbeddingFromJSON convierten el vector a/desde el formato // de almacenamiento en texto. func EmbeddingToJSON(v []float32) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil } func EmbeddingFromJSON(s string) ([]float32, error) { var v []float32 if err := json.Unmarshal([]byte(s), &v); err != nil { return nil, err } return v, nil } func CreateUmindChunks(chunks []UmindChunk) error { if len(chunks) == 0 { return nil } return app.Http.Database.DB.CreateInBatches(chunks, 50).Error } // GetUmindChunksByAgente retorna todos los chunks del agente, para la // búsqueda por similitud en memoria. func GetUmindChunksByAgente(agenteID uint) ([]UmindChunk, error) { var items []UmindChunk err := app.Http.Database.DB.Where("agente_id = ?", agenteID).Find(&items).Error return items, err } // ─── Historial de conversación del widget ─────────────────────────────────── // UmindMensaje guarda el historial de conversación del widget, por agente y // sesión de navegador (no hay usuario autenticado del lado del visitante). type UmindMensaje struct { gorm.Model AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"` SessionID string `json:"session_id" gorm:"column:session_id;index;not null"` Role string `json:"role" gorm:"column:role;not null"` // user | assistant Content string `json:"content" gorm:"column:content;type:text;not null"` } func (UmindMensaje) TableName() string { return "umind_mensajes" } func SaveUmindMensaje(agenteID uint, sessionID, role, content string) error { m := &UmindMensaje{AgenteID: agenteID, SessionID: sessionID, Role: role, Content: content} return app.Http.Database.DB.Create(m).Error } // GetUmindHistorial retorna los últimos n mensajes de una sesión, en orden cronológico. func GetUmindHistorial(agenteID uint, sessionID string, n int) ([]UmindMensaje, error) { var items []UmindMensaje err := app.Http.Database.DB. Where("agente_id = ? AND session_id = ?", agenteID, sessionID). Order("created_at DESC"). Limit(n). Find(&items).Error for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 { items[i], items[j] = items[j], items[i] } return items, err } // GetUmindSesiones lista las sesiones de conversación recientes de un agente // (para el panel admin), con el último mensaje como resumen. func GetUmindSesiones(agenteID uint, limit int) ([]UmindMensaje, error) { var items []UmindMensaje err := app.Http.Database.DB.Raw(` SELECT * FROM ( SELECT DISTINCT ON (session_id) * FROM umind_mensajes WHERE agente_id = ? AND deleted_at IS NULL ORDER BY session_id, created_at DESC ) ultimos ORDER BY created_at DESC LIMIT ? `, agenteID, limit).Scan(&items).Error return items, err } // SesionResumen es una conversación vista desde el reporte: cuándo empezó, // cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es // lo más útil del conjunto — muestra qué le preguntan de verdad al negocio. type SesionResumen struct { SessionID string `json:"session_id"` Inicio time.Time `json:"inicio"` Mensajes int64 `json:"mensajes"` PrimerMensaje string `json:"primer_mensaje"` } // GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un // rango. Una sola consulta: el primer mensaje del visitante se saca con una // subconsulta correlacionada en vez de traer todos los mensajes y agrupar en // Go, que en un agente con tráfico sería traerse el historial entero. func GetUmindSesionesRango(agenteID uint, desde, hasta time.Time) ([]SesionResumen, error) { var items []SesionResumen err := app.Http.Database.DB.Raw(` SELECT m.session_id, MIN(m.created_at) AS inicio, COUNT(*) AS mensajes, COALESCE(( SELECT p.content FROM umind_mensajes p WHERE p.session_id = m.session_id AND p.agente_id = m.agente_id AND p.role = 'user' AND p.deleted_at IS NULL ORDER BY p.created_at ASC LIMIT 1 ), '') AS primer_mensaje FROM umind_mensajes m WHERE m.agente_id = ? AND m.created_at >= ? AND m.created_at < ? AND m.deleted_at IS NULL GROUP BY m.session_id, m.agente_id ORDER BY inicio DESC LIMIT 2000 `, agenteID, desde, hasta).Scan(&items).Error return items, err }