Files
Lizandro GuarnizoandClaude Sonnet 5 08265510ea feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.

- UmindUso registra cada evento facturable con el costo YA calculado al
  precio vigente del plan. Congelarlo evita que subir un precio revalúe
  consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
  en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
  cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
  medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
  servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
  tenant sin plan.

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

666 lines
21 KiB
Go

package controllers
import (
"fmt"
"math"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// ─── Admin pages ──────────────────────────────────────────────────────────────
func ProyectosIndex(c *fiber.Ctx) error {
return c.Render("proyectos", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func ProyectoDetalleIndex(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Redirect("/app/proyectos")
}
proy, err := models.GetProyectoByID(uint(id))
if err != nil {
return c.Redirect("/app/proyectos")
}
return c.Render("proyecto_detalle", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
"proyecto": proy,
}, "layouts/main")
}
// ─── CRUD Proyecto ─────────────────────────────────────────────────────────────
func LoadProyectos(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
search := c.Query("search", "")
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
items, total, err := models.GetAllProyectos(limit, offset, search)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
// Obtener clientes para el formulario
clientes, _, _ := models.GetAllClientes(200, 0, "")
return c.JSON(fiber.Map{
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
"clientes": clientes,
})
}
func CreateProyecto(c *fiber.Ctx) error {
type Req struct {
ClienteID interface{} `json:"cliente_id"`
Nombre string `json:"nombre"`
Slug string `json:"slug"`
Descripcion string `json:"descripcion"`
Color string `json:"color"`
Estado string `json:"estado"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
clienteID := parseUintField(req.ClienteID)
if clienteID == 0 || strings.TrimSpace(req.Nombre) == "" {
return c.Status(400).JSON(fiber.Map{"error": "cliente y nombre son requeridos"})
}
if req.Slug == "" {
req.Slug = slugify(req.Nombre)
}
if req.Color == "" {
req.Color = "#8eb02f"
}
if req.Estado == "" {
req.Estado = "activo"
}
p := &models.Proyecto{
ClienteID: clienteID,
Nombre: req.Nombre,
Slug: req.Slug,
Descripcion: req.Descripcion,
Color: req.Color,
Estado: req.Estado,
}
if err := models.CreateProyecto(p); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(p)
}
func UpdateProyecto(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
type Req struct {
ClienteID interface{} `json:"cliente_id"`
Nombre string `json:"nombre"`
Slug string `json:"slug"`
Descripcion string `json:"descripcion"`
Color string `json:"color"`
Estado string `json:"estado"`
Progreso int `json:"progreso"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
clienteID := parseUintField(req.ClienteID)
p := &models.Proyecto{
ClienteID: clienteID,
Nombre: req.Nombre,
Slug: req.Slug,
Descripcion: req.Descripcion,
Color: req.Color,
Estado: req.Estado,
Progreso: req.Progreso,
}
p.ID = uint(id)
if err := models.UpdateProyecto(p); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteProyecto(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
if err := models.DeleteProyecto(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Fases ────────────────────────────────────────────────────────────────────
func GetFases(c *fiber.Ctx) error {
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
fases, err := models.GetFasesByProyecto(uint(id))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fases)
}
func CreateFase(c *fiber.Ctx) error {
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
type Req struct {
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Estado string `json:"estado"`
Orden int `json:"orden"`
FechaEstimada string `json:"fecha_estimada"`
Entregables []string `json:"entregables"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
f := &models.ProyectoFase{
ProyectoID: uint(proyID),
Nombre: req.Nombre,
Descripcion: req.Descripcion,
Estado: req.Estado,
Orden: req.Orden,
Entregables: req.Entregables,
}
if req.FechaEstimada != "" {
t, err := time.Parse("2006-01-02", req.FechaEstimada)
if err == nil {
f.FechaEstimada = &t
}
}
if err := models.CreateProyectoFase(f); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
f.ParseEntregables()
_ = models.ActualizarProgresoProyecto(uint(proyID))
return c.Status(201).JSON(f)
}
func UpdateFase(c *fiber.Ctx) error {
faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32)
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
type Req struct {
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Estado string `json:"estado"`
Orden int `json:"orden"`
FechaEstimada string `json:"fecha_estimada"`
FechaCompletado string `json:"fecha_completado"`
Entregables []string `json:"entregables"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
f := &models.ProyectoFase{
Nombre: req.Nombre,
Descripcion: req.Descripcion,
Estado: req.Estado,
Orden: req.Orden,
Entregables: req.Entregables,
}
f.ID = uint(faseID)
if req.FechaEstimada != "" {
t, err := time.Parse("2006-01-02", req.FechaEstimada)
if err == nil {
f.FechaEstimada = &t
}
}
if req.FechaCompletado != "" {
t, err := time.Parse("2006-01-02", req.FechaCompletado)
if err == nil {
f.FechaCompletado = &t
}
}
// Auto-set fecha_completado when estado = completado
if req.Estado == "completado" && f.FechaCompletado == nil {
now := time.Now()
f.FechaCompletado = &now
}
if err := models.UpdateProyectoFase(f); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
_ = models.ActualizarProgresoProyecto(uint(proyID))
return c.JSON(fiber.Map{"ok": true})
}
func DeleteFase(c *fiber.Ctx) error {
faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32)
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
if err := models.DeleteProyectoFase(uint(faseID)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
_ = models.ActualizarProgresoProyecto(uint(proyID))
return c.JSON(fiber.Map{"ok": true})
}
func ApplyTemplate(c *fiber.Ctx) error {
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
if err := models.ApplyFaseTemplate(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
_ = models.ActualizarProgresoProyecto(uint(id))
fases, _ := models.GetFasesByProyecto(uint(id))
return c.JSON(fases)
}
// ─── Avances ──────────────────────────────────────────────────────────────────
func GetAvances(c *fiber.Ctx) error {
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
avances, err := models.GetAvancesByProyecto(uint(id), false)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(avances)
}
func CreateAvance(c *fiber.Ctx) error {
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
var a models.ProyectoAvance
if err := c.BodyParser(&a); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
a.ProyectoID = uint(proyID)
if a.Tipo == "" {
a.Tipo = "update"
}
a.Visible = true
if err := models.CreateProyectoAvance(&a); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(a)
}
func UpdateAvance(c *fiber.Ctx) error {
avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32)
var a models.ProyectoAvance
if err := c.BodyParser(&a); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
a.ID = uint(avID)
if err := models.UpdateProyectoAvance(&a); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteAvance(c *fiber.Ctx) error {
avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32)
if err := models.DeleteProyectoAvance(uint(avID)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Entregables ──────────────────────────────────────────────────────────────
func GetEntregables(c *fiber.Ctx) error {
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
items, err := models.GetEntregablesByProyecto(uint(id), false)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
func UploadEntregable(c *fiber.Ctx) error {
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
if _, err := c.MultipartForm(); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Formato multipart requerido"})
}
nombre := c.FormValue("nombre")
descripcion := c.FormValue("descripcion")
version := c.FormValue("version", "1.0")
file, err := c.FormFile("archivo")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"})
}
if file.Size > 50*1024*1024 {
return c.Status(400).JSON(fiber.Map{"error": "Archivo máximo 50MB"})
}
dir := fmt.Sprintf("uploads/proyectos/%d", proyID)
if err := os.MkdirAll(dir, 0755); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
}
safeFile := safeFilenameProyecto(file.Filename)
savePath := filepath.Join(dir, safeFile)
if err := c.SaveFile(file, savePath); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
}
e := &models.ProyectoEntregable{
ProyectoID: uint(proyID),
Nombre: nombre,
Descripcion: descripcion,
Archivo: savePath,
OriginalName: file.Filename,
Version: version,
TipoMime: mimeFromHeader(file),
Tamanio: file.Size,
Visible: true,
}
if err := models.CreateProyectoEntregable(e); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(e)
}
func DeleteEntregable(c *fiber.Ctx) error {
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
item, err := models.GetProyectoEntregableByID(uint(entID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
_ = os.Remove(item.Archivo)
if err := models.DeleteProyectoEntregable(uint(entID)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DownloadEntregable(c *fiber.Ctx) error {
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
item, err := models.GetProyectoEntregableByID(uint(entID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
clean := filepath.Clean(item.Archivo)
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName))
return c.SendFile(clean)
}
func UpdateEntregableVisibilidad(c *fiber.Ctx) error {
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
type Req struct {
Visible bool `json:"visible"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if err := models.UpdateEntregableVisibilidad(uint(entID), req.Visible); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Documentos (contratos / órdenes de servicio) ─────────────────────────────
func GetDocumentos(c *fiber.Ctx) error {
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
items, err := models.GetDocumentosByProyecto(uint(id))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
func UploadDocumento(c *fiber.Ctx) error {
proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32)
if _, err := c.MultipartForm(); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Formato multipart requerido"})
}
nombre := strings.TrimSpace(c.FormValue("nombre"))
descripcion := c.FormValue("descripcion")
tipo := c.FormValue("tipo", "contrato")
validTipos := map[string]bool{"contrato": true, "orden_servicio": true, "otro": true}
if !validTipos[tipo] {
tipo = "otro"
}
file, err := c.FormFile("archivo")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"})
}
if file.Size > 50*1024*1024 {
return c.Status(400).JSON(fiber.Map{"error": "Archivo máximo 50MB"})
}
if nombre == "" {
nombre = file.Filename
}
dir := fmt.Sprintf("uploads/proyectos/%d/docs", proyID)
if err := os.MkdirAll(dir, 0755); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
}
safeFile := safeFilenameProyecto(file.Filename)
savePath := filepath.Join(dir, safeFile)
if err := c.SaveFile(file, savePath); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
}
d := &models.ProyectoDocumento{
ProyectoID: uint(proyID),
Tipo: tipo,
Nombre: nombre,
Descripcion: descripcion,
Archivo: savePath,
OriginalName: file.Filename,
TipoMime: mimeFromHeader(file),
Tamanio: file.Size,
}
if err := models.CreateProyectoDocumento(d); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(d)
}
func DeleteDocumento(c *fiber.Ctx) error {
docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32)
item, err := models.GetProyectoDocumentoByID(uint(docID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
_ = os.Remove(item.Archivo)
if err := models.DeleteProyectoDocumento(uint(docID)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DownloadDocumento(c *fiber.Ctx) error {
docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32)
item, err := models.GetProyectoDocumentoByID(uint(docID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
clean := filepath.Clean(item.Archivo)
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
nombre := item.OriginalName
if nombre == "" {
nombre = item.Nombre
}
c.Set("Content-Disposition", attachmentDisposition(nombre))
return c.SendFile(clean)
}
// attachmentDisposition genera un header Content-Disposition con fallback ASCII y encoding RFC 5987.
func attachmentDisposition(name string) string {
safe := strings.Map(func(r rune) rune {
if r > 127 || r == '"' || r == '\\' || r == '/' || r == '\n' || r == '\r' {
return '_'
}
return r
}, name)
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, safe, url.PathEscape(name))
}
// ─── Tickets (admin) ──────────────────────────────────────────────────────────
func GetTickets(c *fiber.Ctx) error {
id, _ := strconv.ParseUint(c.Params("id"), 10, 32)
items, err := models.GetTicketsByProyecto(uint(id))
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
type Req struct {
Estado string `json:"estado"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if err := models.UpdateTicketEstado(uint(ticketID), req.Estado); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func AdminResponderTicket(c *fiber.Ctx) error {
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
type Req struct {
Contenido string `json:"contenido"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if strings.TrimSpace(req.Contenido) == "" {
return c.Status(400).JSON(fiber.Map{"error": "El mensaje no puede estar vacío"})
}
// Obtener nombre del admin
adminNombre := "Soporte"
if u := c.Locals("user"); u != nil {
if m, ok := u.(map[string]interface{}); ok {
if n, ok := m["Name"].(string); ok && n != "" {
adminNombre = n
}
}
}
msg := &models.TicketMensaje{
TicketID: uint(ticketID),
Contenido: req.Contenido,
EsAdmin: true,
AutorNombre: adminNombre,
}
if err := models.CreateTicketMensaje(msg); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
// Cambiar estado a en_progreso si estaba abierto
_ = models.UpdateTicketEstado(uint(ticketID), "en_progreso")
// Notificar al cliente
go func() {
ticket, err := models.GetTicketByID(uint(ticketID))
if err != nil {
return
}
proyNombre := ""
if ticket.ProyectoID != nil {
if proy, err := models.GetProyectoByID(*ticket.ProyectoID); err == nil {
proyNombre = proy.Nombre
}
}
services.DispatchTicketRespuestaAdmin(ticket, req.Contenido, proyNombre)
}()
return c.Status(201).JSON(msg)
}
// ─── Tickets global (admin) ──────────────────────────────────────────────────
func TicketsAdminIndex(c *fiber.Ctx) error {
return c.Render("tickets_admin", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
func GetAllTicketsAdmin(c *fiber.Ctx) error {
estado := c.Query("estado", "todos")
items, err := models.GetAllTickets(estado)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(items)
}
// ─── helpers ──────────────────────────────────────────────────────────────────
func slugify(s string) string {
s = strings.ToLower(s)
replacer := strings.NewReplacer(
" ", "-", "á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u",
"ñ", "n", "ü", "u",
)
s = replacer.Replace(s)
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
}
}
return strings.Trim(b.String(), "-")
}
// parseUintField convierte interface{} (string o float64 del JSON) a uint de forma segura.
func parseUintField(v interface{}) uint {
switch val := v.(type) {
case float64:
return uint(val)
case string:
n, _ := strconv.ParseUint(strings.TrimSpace(val), 10, 64)
return uint(n)
}
return 0
}
func safeFilenameProyecto(name string) string {
base := filepath.Base(name)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '_'
}, stem)
ts := strconv.FormatInt(time.Now().UnixNano(), 36)
return safe + "_" + ts + ext
}
func mimeFromHeader(fh *multipart.FileHeader) string {
ct := fh.Header.Get("Content-Type")
if ct == "" {
return "application/octet-stream"
}
return ct
}