Files
soft_usite/pkg/models/umind.go
T
Lizandro GuarnizoandClaude Sonnet 5 c69b904b03 feat(umind): vincula tenants a clientes y agrega planes con precios
Base para que el cliente administre uMind desde su portal y para el cobro
por consumo. Todavía no cambia nada del comportamiento actual.

- UmindTenant gana ClienteID (punteros, sin not null: los tenants creados
  antes del portal quedan sin asignar y el ALTER TABLE no falla sobre
  datos existentes) y PlanID.
- GetUmindTenantsByClientes es fail-closed: sin clientes, no ve nada.
- UmindPlan define el máximo de agentes y los precios por 1k tokens, por
  imagen OCR, por transcripción y la mensualidad, más un tope de consumo
  que solo avisa. CRUD para staff en /app/umind-planes.
- El modelo va en AMBAS listas de AutoMigrate (main.go y migrations),
  no repetir el error de agregarlo solo en una.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:32:38 -05:00

271 lines
9.7 KiB
Go

package models
import (
"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
}