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>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 11:32:38 -05:00
co-authored by Claude Sonnet 5
parent 84506a98e2
commit c69b904b03
14 changed files with 493 additions and 33 deletions
+1
View File
@@ -156,6 +156,7 @@ func main() {
&models.UmindCanal{},
&models.UmindConexion{},
&models.UmindEventoLog{},
&models.UmindPlan{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
// Integraciones: OCR y transcripción de audio (servicios propios)
+24 -2
View File
@@ -127,6 +127,7 @@ func Migrate() {
&models.UmindCanal{},
&models.UmindConexion{},
&models.UmindEventoLog{},
&models.UmindPlan{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
// Integraciones: OCR y transcripción de audio (servicios propios)
@@ -1326,12 +1327,33 @@ func SeedUmind() {
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 {
asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
log.Printf("[SEED] Submódulo 'uMind' asignado al rol 'Administrador'")
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.")
}
+51 -2
View File
@@ -7,6 +7,8 @@ const route = useRoute()
const router = useRouter()
const tenants = ref([])
const clientes = ref([])
const planes = ref([])
const loading = ref(true)
const error = ref('')
const showForm = ref(false)
@@ -18,7 +20,7 @@ const form = ref(vacio())
const tenantActivoId = computed(() => route.params.tenantId || route.params.id)
function vacio() {
return { nombre: '', dominios_permitidos: '', activo: true }
return { nombre: '', dominios_permitidos: '', activo: true, cliente_id: null, plan_id: null }
}
async function cargar() {
@@ -34,6 +36,22 @@ async function cargar() {
}
}
// Clientes y planes solo los necesita el staff para asignarlos; si el endpoint
// no está disponible (portal del cliente) el formulario sigue funcionando.
async function cargarAsignables() {
try {
const [c, p] = await Promise.all([
api.get('/app/api/clientes/select'),
api.get('/app/umind-planes/list'),
])
clientes.value = c.registros || c.items || []
planes.value = p.items || []
} catch {
clientes.value = []
planes.value = []
}
}
function nuevoTenant() {
editing.value = null
form.value = vacio()
@@ -46,6 +64,8 @@ function editarTenant(t) {
nombre: t.nombre,
dominios_permitidos: t.dominios_permitidos,
activo: t.activo,
cliente_id: t.cliente_id ?? null,
plan_id: t.plan_id ?? null,
}
showForm.value = true
}
@@ -82,7 +102,10 @@ async function eliminar(t) {
}
defineExpose({ recargar: cargar })
onMounted(cargar)
onMounted(() => {
cargar()
cargarAsignables()
})
</script>
<template>
@@ -175,6 +198,32 @@ onMounted(cargar)
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div v-if="clientes.length" class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Cliente</label>
<select
v-model="form.cliente_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
>
<option :value="null"> sin asignar </option>
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
</select>
<p class="text-[11px] text-gray-400 mt-1">Define quién ve este tenant desde el portal.</p>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Plan</label>
<select
v-model="form.plan_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
>
<option :value="null"> sin plan </option>
<option v-for="p in planes" :key="p.ID" :value="p.ID">
{{ p.nombre }} ({{ p.max_agentes === 0 ? '' : p.max_agentes }} agentes)
</option>
</select>
<p class="text-[11px] text-gray-400 mt-1">Límite de agentes y precios de consumo.</p>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input v-model="form.activo" type="checkbox" />
Activo
+18
View File
@@ -23,6 +23,12 @@ type UmindTenant struct {
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" }
@@ -55,6 +61,18 @@ func GetAllUmindTenants(limit, offset int) ([]UmindTenant, int64, error) {
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 {
+66
View File
@@ -0,0 +1,66 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindPlan define qué puede hacer un tenant y a qué precio. Los precios se
// copian a cada UmindUso al momento de registrarlo, así que subir un precio
// nunca revalúa consumo ya facturado ni pendiente.
type UmindPlan struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:100;not null"`
MaxAgentes int `json:"max_agentes" gorm:"column:max_agentes;default:1"` // 0 = ilimitado
PrecioMensual float64 `json:"precio_mensual" gorm:"column:precio_mensual;default:0"`
PrecioPor1kTokens float64 `json:"precio_por_1k_tokens" gorm:"column:precio_por_1k_tokens;default:0"`
PrecioPorOCR float64 `json:"precio_por_ocr" gorm:"column:precio_por_ocr;default:0"`
PrecioPorTranscripcion float64 `json:"precio_por_transcripcion" gorm:"column:precio_por_transcripcion;default:0"`
Moneda string `json:"moneda" gorm:"column:moneda;size:3;default:'COP'"`
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el
// servicio. 0 = sin tope.
TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (UmindPlan) TableName() string { return "umind_planes" }
func CreateUmindPlan(p *UmindPlan) error {
return app.Http.Database.DB.Create(p).Error
}
func GetUmindPlanes() ([]UmindPlan, error) {
var items []UmindPlan
err := app.Http.Database.DB.Order("id ASC").Find(&items).Error
return items, err
}
func GetUmindPlanByID(id uint) (*UmindPlan, error) {
var p UmindPlan
if err := app.Http.Database.DB.First(&p, id).Error; err != nil {
return nil, err
}
return &p, nil
}
func UpdateUmindPlan(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindPlan{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindPlan(id uint) error {
return app.Http.Database.DB.Delete(&UmindPlan{}, id).Error
}
// GetPlanDeTenant devuelve el plan del tenant, o nil si no tiene uno asignado
// (tenants viejos). Los llamadores tratan nil como "sin límites ni precios".
func GetPlanDeTenant(tenantID uint) *UmindPlan {
t, err := GetUmindTenantByID(tenantID)
if err != nil || t.PlanID == nil {
return nil
}
p, err := GetUmindPlanByID(*t.PlanID)
if err != nil {
return nil
}
return p
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind — Orquestador</title>
<script type="module" crossorigin src="/orchestrator/assets/index-D1KpwOlP.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-a_0kTstc.css">
<script type="module" crossorigin src="/orchestrator/assets/index-BfxLbsxs.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C6K_wyko.css">
</head>
<body class="bg-gray-50">
<div id="app"></div>
+177
View File
@@ -0,0 +1,177 @@
<div x-data="umindPlanesApp()" x-init="init()" class="p-6 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800">Planes de uMind</h1>
<p class="text-sm text-slate-500 mt-1">Límite de agentes y precios por consumo que se aplican a cada tenant</p>
</div>
<button class="btn-primary" @click="nuevo()">+ Nuevo plan</button>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-3 py-2 text-left">Plan</th>
<th class="px-3 py-2 text-right">Agentes</th>
<th class="px-3 py-2 text-right">Mensualidad</th>
<th class="px-3 py-2 text-right">1k tokens</th>
<th class="px-3 py-2 text-right">OCR</th>
<th class="px-3 py-2 text-right">Transcripción</th>
<th class="px-3 py-2 text-right">Tope</th>
<th class="px-3 py-2 text-left">Estado</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="p in planes" :key="p.ID">
<tr class="hover:bg-slate-50">
<td class="px-3 py-2 font-medium text-slate-700" x-text="p.nombre"></td>
<td class="px-3 py-2 text-right text-slate-600" x-text="p.max_agentes === 0 ? 'ilimitado' : p.max_agentes"></td>
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_mensual, p.moneda)"></td>
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_por_1k_tokens, p.moneda)"></td>
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_por_ocr, p.moneda)"></td>
<td class="px-3 py-2 text-right text-slate-600" x-text="money(p.precio_por_transcripcion, p.moneda)"></td>
<td class="px-3 py-2 text-right text-slate-600" x-text="p.tope_consumo_mensual ? money(p.tope_consumo_mensual, p.moneda) : '—'"></td>
<td class="px-3 py-2">
<span class="badge" :class="p.activo?'badge-green':'badge-slate'" x-text="p.activo?'activo':'inactivo'"></span>
</td>
<td class="px-3 py-2 text-right whitespace-nowrap">
<button class="text-slate-400 hover:text-slate-700 px-1" @click="editar(p)">Editar</button>
<button class="text-red-500 hover:text-red-700 px-1" @click="eliminar(p)">Eliminar</button>
</td>
</tr>
</template>
<template x-if="!planes.length">
<tr><td colspan="9" class="text-center py-8 text-slate-400">Sin planes todavía. Creá el primero para poder asignarlo a un tenant.</td></tr>
</template>
</tbody>
</table>
</div>
</div>
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
<!-- Modal -->
<div x-show="showForm" x-cloak class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm=false">
<div class="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold text-slate-800 mb-4" x-text="form.id?'Editar plan':'Nuevo plan'"></h2>
<form @submit.prevent="guardar()">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="label">Nombre</label>
<input x-model="form.nombre" class="input-field w-full" required placeholder="Básico">
</div>
<div>
<label class="label">Máximo de agentes</label>
<input x-model.number="form.max_agentes" type="number" min="0" class="input-field w-full">
<p class="text-xs text-slate-400 mt-1">0 = ilimitado</p>
</div>
<div>
<label class="label">Moneda</label>
<input x-model="form.moneda" maxlength="3" class="input-field w-full" placeholder="COP">
</div>
<div>
<label class="label">Mensualidad</label>
<input x-model.number="form.precio_mensual" type="number" step="any" min="0" class="input-field w-full">
</div>
<div>
<label class="label">Precio por 1.000 tokens</label>
<input x-model.number="form.precio_por_1k_tokens" type="number" step="any" min="0" class="input-field w-full">
</div>
<div>
<label class="label">Precio por imagen (OCR)</label>
<input x-model.number="form.precio_por_ocr" type="number" step="any" min="0" class="input-field w-full">
</div>
<div>
<label class="label">Precio por transcripción</label>
<input x-model.number="form.precio_por_transcripcion" type="number" step="any" min="0" class="input-field w-full">
</div>
<div class="col-span-2">
<label class="label">Tope de consumo mensual</label>
<input x-model.number="form.tope_consumo_mensual" type="number" step="any" min="0" class="input-field w-full">
<p class="text-xs text-slate-400 mt-1">Al superarlo se avisa al cliente y al staff, pero el servicio sigue funcionando. 0 = sin tope.</p>
</div>
<div class="col-span-2" x-show="form.id">
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" x-model="form.activo" class="rounded border-slate-300">
Plan activo
</label>
</div>
</div>
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
<div class="flex justify-end gap-2 mt-5">
<button type="button" class="btn-secondary" @click="showForm=false">Cancelar</button>
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar'"></button>
</div>
</form>
</div>
</div>
</div>
<script>
function umindPlanesApp() {
return {
planes: [], showForm:false, saving:false, error:'',
form: vacio(),
async init(){ await this.cargar(); },
async cargar(){
try { const r = await axios.get('/app/umind-planes/list'); this.planes = r.data.items || []; }
catch(e){ this.error = e.response?.data?.error || 'Error al cargar'; }
},
nuevo(){ this.form = vacio(); this.error=''; this.showForm = true; },
editar(p){
this.form = {
id: p.ID, nombre: p.nombre, max_agentes: p.max_agentes,
precio_mensual: p.precio_mensual, precio_por_1k_tokens: p.precio_por_1k_tokens,
precio_por_ocr: p.precio_por_ocr, precio_por_transcripcion: p.precio_por_transcripcion,
moneda: p.moneda, tope_consumo_mensual: p.tope_consumo_mensual, activo: p.activo,
};
this.error=''; this.showForm = true;
},
async guardar(){
this.saving = true; this.error = '';
try {
if (this.form.id) await axios.put('/app/umind-planes/' + this.form.id, this.form);
else await axios.post('/app/umind-planes', this.form);
this.showForm = false;
await this.cargar();
} catch(e){ this.error = e.response?.data?.error || 'Error al guardar'; }
finally { this.saving = false; }
},
async eliminar(p){
if (!confirm('¿Eliminar el plan "' + p.nombre + '"? Los tenants que lo tengan asignado quedan sin plan.')) return;
try { await axios.delete('/app/umind-planes/' + p.ID); await this.cargar(); }
catch(e){ this.error = e.response?.data?.error || 'Error al eliminar'; }
},
money(v, m){ return (m||'COP') + ' ' + Number(v||0).toLocaleString('es-CO', {maximumFractionDigits:4}); },
}
}
function vacio(){
return { id:0, nombre:'', max_agentes:1, precio_mensual:0, precio_por_1k_tokens:0,
precio_por_ocr:0, precio_por_transcripcion:0, moneda:'COP', tope_consumo_mensual:0, activo:true };
}
</script>
<style>
[x-cloak] { display: none !important; }
.btn-primary { background:#8eb02f; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; transition:background .15s; }
.btn-primary:hover { background:#6d8c24; }
.btn-primary:disabled { opacity:.6; cursor:not-allowed; }
.btn-secondary { background:#f1f5f9; color:#475569; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:500; font-size:.875rem; border:1px solid #e2e8f0; }
.input-field { border:1px solid #e2e8f0; border-radius:0.5rem; padding:0.5rem 0.75rem; font-size:.875rem; outline:none; transition:border-color .15s; }
.input-field:focus { border-color:#8eb02f; }
.label { display:block; font-size:.75rem; font-weight:600; color:#475569; margin-bottom:.25rem; text-transform:uppercase; letter-spacing:.05em; }
.badge { display:inline-block; padding:.15rem .6rem; border-radius:9999px; font-size:.7rem; font-weight:600; }
.badge-green { background:#dcfce7; color:#15803d; }
.badge-slate { background:#f1f5f9; color:#475569; }
</style>
@@ -61,6 +61,8 @@ type umindTenantReq struct {
Nombre string `json:"nombre"`
DominiosPermitidos []string `json:"dominios_permitidos"`
Activo bool `json:"activo"`
ClienteID *uint `json:"cliente_id"`
PlanID *uint `json:"plan_id"`
}
func (r umindTenantReq) dominiosLimpios() []string {
@@ -92,6 +94,8 @@ func CreateUmindTenantHandler(c *fiber.Ctx) error {
DominiosPermitidos: strings.Join(dominios, ","),
Activo: true,
CreadoPorID: extraerUserID(c),
ClienteID: req.ClienteID,
PlanID: req.PlanID,
}
if err := models.CreateUmindTenant(tenant); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -113,6 +117,8 @@ func UpdateUmindTenantHandler(c *fiber.Ctx) error {
"nombre": strings.TrimSpace(req.Nombre),
"dominios_permitidos": strings.Join(dominios, ","),
"activo": req.Activo,
"cliente_id": req.ClienteID,
"plan_id": req.PlanID,
}
if err := models.UpdateUmindTenant(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+115
View File
@@ -0,0 +1,115 @@
package controllers
import (
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// UmindPlanesPage renderiza la administración de planes de uMind (solo staff).
func UmindPlanesPage(c *fiber.Ctx) error {
return c.Render("umind_planes", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func GetUmindPlanesHandler(c *fiber.Ctx) error {
items, err := models.GetUmindPlanes()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
type umindPlanReq struct {
Nombre string `json:"nombre"`
MaxAgentes int `json:"max_agentes"`
PrecioMensual float64 `json:"precio_mensual"`
PrecioPor1kTokens float64 `json:"precio_por_1k_tokens"`
PrecioPorOCR float64 `json:"precio_por_ocr"`
PrecioPorTranscripcion float64 `json:"precio_por_transcripcion"`
Moneda string `json:"moneda"`
TopeConsumoMensual float64 `json:"tope_consumo_mensual"`
Activo bool `json:"activo"`
}
func (r umindPlanReq) moneda() string {
m := strings.ToUpper(strings.TrimSpace(r.Moneda))
if len(m) != 3 {
return "COP"
}
return m
}
func CreateUmindPlanHandler(c *fiber.Ctx) error {
var req umindPlanReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
if req.MaxAgentes < 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "max_agentes no puede ser negativo"})
}
plan := &models.UmindPlan{
Nombre: strings.TrimSpace(req.Nombre),
MaxAgentes: req.MaxAgentes,
PrecioMensual: req.PrecioMensual,
PrecioPor1kTokens: req.PrecioPor1kTokens,
PrecioPorOCR: req.PrecioPorOCR,
PrecioPorTranscripcion: req.PrecioPorTranscripcion,
Moneda: req.moneda(),
TopeConsumoMensual: req.TopeConsumoMensual,
Activo: true,
}
if err := models.CreateUmindPlan(plan); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": plan.ID})
}
func UpdateUmindPlanHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
var req umindPlanReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.MaxAgentes < 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "max_agentes no puede ser negativo"})
}
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"max_agentes": req.MaxAgentes,
"precio_mensual": req.PrecioMensual,
"precio_por_1k_tokens": req.PrecioPor1kTokens,
"precio_por_ocr": req.PrecioPorOCR,
"precio_por_transcripcion": req.PrecioPorTranscripcion,
"moneda": req.moneda(),
"tope_consumo_mensual": req.TopeConsumoMensual,
"activo": req.Activo,
}
if err := models.UpdateUmindPlan(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindPlanHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteUmindPlan(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
+6
View File
@@ -383,6 +383,12 @@ func UserRoutes(app fiber.Router) {
protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler)
protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler)
protected.Get("/umind/eventos", controllers.GetUmindEventosHandler)
// Planes: definen el límite de agentes y los precios por consumo. Solo staff.
protected.Get("/umind-planes", middlewares.MenuMiddleware, controllers.UmindPlanesPage)
protected.Get("/umind-planes/list", controllers.GetUmindPlanesHandler)
protected.Post("/umind-planes", middlewares.SoloAdmin, controllers.CreateUmindPlanHandler)
protected.Put("/umind-planes/:id", middlewares.SoloAdmin, controllers.UpdateUmindPlanHandler)
protected.Delete("/umind-planes/:id", middlewares.SoloAdmin, controllers.DeleteUmindPlanHandler)
// ─── uMind Orquestador (SPA Vue) ────────────────────────────────────────────
// Estáticos reales (JS/CSS del build) ya los sirve el Static("/") general