Files
Lizandro GuarnizoandClaude Sonnet 5 8ef49c5169 feat: bucles de crecimiento y activación de uMind
Cuatro cosas que ya estaban a medio construir y no se usaban como canal.

1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te
   estaba dando exposición en su sitio y no se capitalizaba. El host sale
   de la URL del propio script, así funciona igual en cualquier entorno.

2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué
   las abrió el visitante, y el consumo desglosado. Es lo que el cliente
   necesita para justificar el gasto puertas adentro — un panel al que hay
   que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el
   generador de xlsx del cronograma.

3. Aviso automático de agentes sin base de conocimiento (cron diario). Un
   agente sin fuentes responde de memoria e inventa datos, el cliente
   concluye que el producto no sirve y se va. Es el punto donde más gente
   se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de
   auditoría como registro de envío para no necesitar tabla nueva ni
   convertir el recordatorio en spam.

4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo
   tiene, oferta si no. Es el punto de contacto más barato que hay — ya
   entró, ya confía, y el cobro se suma a la factura que ya recibe. El
   mensaje lidera con notas de voz y fotos, que es lo más difícil de
   copiar de lo que tenemos.

La consulta de conversaciones agrupa en SQL en vez de traerse el historial
entero para agrupar en Go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:40:28 -05:00

999 lines
34 KiB
Go

package controllers
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
// ─── Auth ─────────────────────────────────────────────────────────────────────
func PortalLoginPage(c *fiber.Ctx) error {
if user, err := auth.PortalUser(c); err == nil && user != nil {
return c.Redirect("/portal/dashboard")
}
return c.Render("portal/login", fiber.Map{
"error": c.Query("error"),
"success": c.Query("success"),
}, "layouts/portal_public")
}
func PortalLoginPost(c *fiber.Ctx) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
u, err := models.CheckPortalLogin(email, password)
if err != nil {
return c.Redirect("/portal/login?error=" + url.QueryEscape(err.Error()))
}
if err := auth.SetPortalSession(c, u.ID); err != nil {
return c.Redirect("/portal/login?error=Error+interno")
}
return c.Redirect("/portal/dashboard")
}
func PortalLogout(c *fiber.Ctx) error {
_ = auth.DestroyPortalSession(c)
return c.Redirect("/portal/login")
}
// ─── Recuperar contraseña ────────────────────────────────────────────────────
func PortalForgotPasswordPage(c *fiber.Ctx) error {
return c.Render("portal/forgot_password", fiber.Map{
"success": c.Query("success"),
"error": c.Query("error"),
}, "layouts/portal_public")
}
func PortalForgotPasswordPost(c *fiber.Ctx) error {
email := strings.TrimSpace(c.FormValue("email"))
// Respuesta genérica siempre para no revelar si el email existe
successMsg := "Si el correo está registrado, recibirás un enlace para restablecer tu contraseña."
u, err := models.GetPortalUserByEmail(email)
if err == nil && u != nil && u.Activo {
token, err := models.CreatePortalResetToken(u.ID)
if err == nil {
resetLink := services.GetPublicURL() + "/portal/reset-password?t=" + token
services.SendPortalPasswordResetEmail(u.Email, u.Nombre, resetLink)
} else {
log.Printf("[Portal] Error creando token de reseteo para %s: %v", email, err)
}
}
return c.Redirect("/portal/forgot-password?success=" + url.QueryEscape(successMsg))
}
func PortalResetPasswordPage(c *fiber.Ctx) error {
token := c.Query("t")
if token == "" {
return c.Redirect("/portal/login")
}
_, err := models.GetValidPortalResetToken(token)
if err != nil {
return c.Render("portal/reset_password", fiber.Map{
"error": err.Error(),
"token": "",
}, "layouts/portal_public")
}
return c.Render("portal/reset_password", fiber.Map{
"token": token,
"error": "",
}, "layouts/portal_public")
}
func PortalResetPasswordPost(c *fiber.Ctx) error {
token := c.FormValue("token")
password := c.FormValue("password")
confirm := c.FormValue("confirm")
renderError := func(msg string) error {
return c.Render("portal/reset_password", fiber.Map{
"token": token,
"error": msg,
}, "layouts/portal_public")
}
if token == "" || password == "" || confirm == "" {
return renderError("Todos los campos son obligatorios.")
}
if password != confirm {
return renderError("Las contraseñas no coinciden.")
}
if len(password) < 8 {
return renderError("La contraseña debe tener al menos 8 caracteres.")
}
record, err := models.GetValidPortalResetToken(token)
if err != nil {
return renderError(err.Error())
}
hashed, err := app.Http.Hash.Create(password)
if err != nil {
return renderError("Error al procesar la contraseña. Intenta de nuevo.")
}
if err := models.UpdatePortalUserPassword(record.PortalUserID, hashed); err != nil {
return renderError("Error al actualizar la contraseña. Intenta de nuevo.")
}
_ = models.MarkPortalResetTokenUsed(record.ID)
return c.Redirect("/portal/login?success=" + url.QueryEscape("Contraseña actualizada. Ya puedes ingresar."))
}
// ─── Dashboard ────────────────────────────────────────────────────────────────
func PortalDashboard(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Redirect("/portal/login")
}
// Recargar con accesos
fullUser, err := models.GetPortalUserByID(u.ID)
if err != nil {
return c.Redirect("/portal/login")
}
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
proyectos, err := models.GetProyectosByClienteIDs(clienteIDs)
if err != nil {
// Antes este error se descartaba y el usuario veía "no tenés
// proyectos": indistinguible de una caída de la base.
log.Printf("[PORTAL] dashboard: no se pudieron cargar los proyectos del usuario %d: %v", fullUser.ID, err)
return c.Status(500).Render("portal/dashboard", fiber.Map{
"portalUser": fullUser,
"error": "No pudimos cargar tus proyectos en este momento. Volvé a intentar en un minuto.",
"isPartner": fullUser.EsPartner(),
}, "layouts/portal")
}
models.AplicarProgreso(proyectos)
// Si el cliente todavía no tiene uMind, el dashboard es el mejor lugar
// para ofrecérselo: ya entró, ya confía, y el cobro se suma a la factura
// que ya recibe. Si ya lo tiene, se le ofrece el atajo en vez del pitch.
tenantsUmind, _ := models.GetUmindTenantsByClientes(clienteIDs)
// Si partner: agrupar proyectos por cliente
type ClienteProyectos struct {
Cliente models.Cliente
Proyectos []models.Proyecto
}
var grupos []ClienteProyectos
clienteMap := map[uint]*ClienteProyectos{}
for _, p := range proyectos {
if _, ok := clienteMap[p.ClienteID]; !ok {
clienteMap[p.ClienteID] = &ClienteProyectos{Cliente: p.Cliente}
}
clienteMap[p.ClienteID].Proyectos = append(clienteMap[p.ClienteID].Proyectos, p)
}
for _, g := range clienteMap {
grupos = append(grupos, *g)
}
// El recorrido de un map en Go es aleatorio: sin ordenar, un partner veía
// sus clientes en distinto orden en cada recarga.
sort.Slice(grupos, func(i, j int) bool { return grupos[i].Cliente.Nombre < grupos[j].Cliente.Nombre })
return c.Render("portal/dashboard", fiber.Map{
"portalUser": fullUser,
"proyectos": proyectos,
"grupos": grupos,
"isPartner": fullUser.EsPartner(),
"tieneUmind": len(tenantsUmind) > 0,
}, "layouts/portal")
}
// ─── Detalle de proyecto ──────────────────────────────────────────────────────
func PortalProyecto(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Redirect("/portal/login")
}
slug := c.Params("slug")
proy, err := models.GetProyectoBySlug(slug)
if err != nil {
return c.Status(404).Render("portal/404", fiber.Map{}, "layouts/portal")
}
// Verificar acceso
fullUser, _ := models.GetPortalUserByID(u.ID)
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
hasAccess := false
for _, cid := range clienteIDs {
if cid == proy.ClienteID {
hasAccess = true
break
}
}
if !hasAccess {
return c.Status(403).Redirect("/portal/dashboard")
}
fases, _ := models.GetFasesByProyecto(proy.ID)
avances, _ := models.GetAvancesByProyecto(proy.ID, true)
entregables, _ := models.GetEntregablesByProyecto(proy.ID, true)
tickets, _ := models.GetTicketsByProyecto(proy.ID)
proy.Progreso = models.ProgresoPorProyecto([]uint{proy.ID})[proy.ID]
return c.Render("portal/proyecto", fiber.Map{
"portalUser": fullUser,
"proyecto": proy,
"fases": fases,
"avances": avances,
"entregables": entregables,
"tickets": tickets,
}, "layouts/portal")
}
// ─── API del portal ───────────────────────────────────────────────────────────
// PortalGetProyectos devuelve los proyectos del usuario logueado (JSON para Alpine).
func PortalGetProyectos(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
fullUser, _ := models.GetPortalUserByID(u.ID)
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
proyectos, err := models.GetProyectosByClienteIDs(clienteIDs)
if err != nil {
// Antes este error se descartaba y el usuario veía "no tenés
// proyectos": indistinguible de una caída de la base.
log.Printf("[PORTAL] dashboard: no se pudieron cargar los proyectos del usuario %d: %v", fullUser.ID, err)
return c.Status(500).Render("portal/dashboard", fiber.Map{
"portalUser": fullUser,
"error": "No pudimos cargar tus proyectos en este momento. Volvé a intentar en un minuto.",
"isPartner": fullUser.EsPartner(),
}, "layouts/portal")
}
models.AplicarProgreso(proyectos)
return c.JSON(proyectos)
}
// PortalGetProyectoData devuelve todo el contenido de un proyecto (JSON).
func PortalGetProyectoData(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
slug := c.Params("slug")
proy, err := models.GetProyectoBySlug(slug)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "no encontrado"})
}
// Verificar acceso
fullUser, _ := models.GetPortalUserByID(u.ID)
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
hasAccess := false
for _, cid := range clienteIDs {
if cid == proy.ClienteID {
hasAccess = true
break
}
}
if !hasAccess {
return c.Status(403).JSON(fiber.Map{"error": "sin acceso"})
}
fases, _ := models.GetFasesByProyecto(proy.ID)
avances, _ := models.GetAvancesByProyecto(proy.ID, true)
entregables, _ := models.GetEntregablesByProyecto(proy.ID, true)
tickets, _ := models.GetTicketsByProyecto(proy.ID)
facturas, _ := models.GetFacturasByCliente(proy.ClienteID, true)
facturasNoLeidas := models.CountUnreadFacturaNotifs(u.ID)
documentos, _ := models.GetDocumentosByProyecto(proy.ID)
proy.Progreso = models.ProgresoPorProyecto([]uint{proy.ID})[proy.ID]
return c.JSON(fiber.Map{
"proyecto": proy,
"fases": fases,
"avances": avances,
"entregables": entregables,
"documentos": documentos,
"tickets": tickets,
"facturas": facturas,
"facturasNoLeidas": facturasNoLeidas,
})
}
// PortalMarcarFacturasLeidas marca las notificaciones de facturas como leídas.
func PortalMarcarFacturasLeidas(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
_ = models.MarcarNotifsFacturasLeidas(u.ID)
return c.JSON(fiber.Map{"ok": true})
}
// PortalCrearTicket crea un nuevo ticket de soporte.
func PortalCrearTicket(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
type Req struct {
ProyectoSlug string `json:"proyecto_slug"`
Titulo string `json:"titulo"`
Descripcion string `json:"descripcion"`
Prioridad string `json:"prioridad"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if strings.TrimSpace(req.Titulo) == "" {
return c.Status(400).JSON(fiber.Map{"error": "El título es requerido"})
}
proy, err := models.GetProyectoBySlug(req.ProyectoSlug)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"})
}
t := &models.ProyectoTicket{
ProyectoID: &proy.ID,
PortalUserID: &u.ID,
AutorNombre: u.Nombre,
Titulo: req.Titulo,
Descripcion: req.Descripcion,
Prioridad: req.Prioridad,
Estado: "abierto",
}
if t.Prioridad == "" {
t.Prioridad = "media"
}
if err := models.CreateProyectoTicket(t); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
go services.DispatchTicketNuevo(t, u, proy.Nombre)
return c.Status(201).JSON(t)
}
// PortalResponderTicket agrega un mensaje a un ticket.
func PortalResponderTicket(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
ticketID, _ := c.ParamsInt("id")
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"})
}
ticket, err := models.GetTicketByID(uint(ticketID))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
}
if ticket.PortalUserID == nil || *ticket.PortalUserID != u.ID {
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
}
msg := &models.TicketMensaje{
TicketID: uint(ticketID),
Contenido: req.Contenido,
EsAdmin: false,
AutorNombre: u.Nombre,
}
if err := models.CreateTicketMensaje(msg); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
go func() {
proyNombre := ""
if ticket.ProyectoID != nil {
if proy, err := models.GetProyectoByID(*ticket.ProyectoID); err == nil {
proyNombre = proy.Nombre
}
}
services.DispatchTicketRespuestaCliente(ticket, req.Contenido, proyNombre)
}()
return c.Status(201).JSON(msg)
}
// PortalDownloadEntregable descarga un entregable del portal.
func PortalDownloadEntregable(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
entID, _ := c.ParamsInt("id")
item, err := models.GetProyectoEntregableByID(uint(entID))
if err != nil || !item.Visible {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
// Verificar que el proyecto pertenece al cliente del portal user
proy, err := models.GetProyectoByID(item.ProyectoID)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"})
}
fullUser, _ := models.GetPortalUserByID(u.ID)
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
hasAccess := false
for _, cid := range clienteIDs {
if cid == proy.ClienteID {
hasAccess = true
break
}
}
if !hasAccess {
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
}
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)
}
// PortalDownloadFactura descarga el PDF de una factura del portal.
func PortalDownloadFactura(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
factID, _ := c.ParamsInt("id")
f, err := models.GetFacturaByID(uint(factID))
if err != nil || !f.Visible || f.Archivo == "" {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
// Verificar acceso
fullUser, _ := models.GetPortalUserByID(u.ID)
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
hasAccess := false
for _, cid := range clienteIDs {
if cid == f.ClienteID {
hasAccess = true
break
}
}
if !hasAccess {
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
}
clean := f.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"`, f.OriginalName))
return c.SendFile(clean)
}
// PortalDownloadDocumento descarga un documento del proyecto (contrato, orden de servicio, etc.).
func PortalDownloadDocumento(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
docID, _ := c.ParamsInt("id")
doc, err := models.GetProyectoDocumentoByID(uint(docID))
if err != nil || doc.Archivo == "" {
return c.Status(404).JSON(fiber.Map{"error": "No encontrado"})
}
// Verificar que el proyecto pertenece al cliente del portal user
proy, err := models.GetProyectoByID(doc.ProyectoID)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"})
}
fullUser, _ := models.GetPortalUserByID(u.ID)
clienteIDs := models.GetClienteIDsForPortalUser(fullUser)
hasAccess := false
for _, cid := range clienteIDs {
if cid == proy.ClienteID {
hasAccess = true
break
}
}
if !hasAccess {
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
}
clean := filepath.Clean(doc.Archivo)
if !strings.HasPrefix(clean, "uploads/") {
return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"})
}
nombre := doc.OriginalName
if nombre == "" {
nombre = doc.Nombre
}
safe := strings.Map(func(r rune) rune {
if r > 127 || r == '"' || r == '\\' || r == '/' || r == '\n' || r == '\r' {
return '_'
}
return r
}, nombre)
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, safe, url.PathEscape(nombre)))
return c.SendFile(clean)
}
// ─── Notificaciones del portal user ──────────────────────────────────────────
func PortalGetMisNotifs(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
soloNoLeidas := c.Query("no_leidas") == "1"
items, err := models.GetSistemaNotifs("portal_user", u.ID, soloNoLeidas)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
count := models.CountUnreadNotifs("portal_user", u.ID)
return c.JSON(fiber.Map{"items": items, "unread": count})
}
func PortalMarcarNotifLeida(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
id, _ := c.ParamsInt("id")
if err := models.MarcarNotifLeida(uint(id)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func PortalMarcarTodasLeidas(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
if err := models.MarcarTodasLeidas("portal_user", u.ID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// PortalMarcarTicketLeido marca como leídos todos los mensajes de admin del ticket.
func PortalMarcarTicketLeido(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
ticketID, _ := c.ParamsInt("id")
// Verificar que el ticket pertenece al portal user
ticket, err := models.GetTicketByID(uint(ticketID))
if err != nil || ticket == nil {
return c.Status(404).JSON(fiber.Map{"error": "ticket no encontrado"})
}
if ticket.PortalUserID == nil || *ticket.PortalUserID != u.ID {
return c.Status(403).JSON(fiber.Map{"error": "sin permiso"})
}
_ = models.MarkTicketMessagesReadByPortal(uint(ticketID))
return c.JSON(fiber.Map{"ok": true})
}
// ─── Mi Perfil ────────────────────────────────────────────────────────────────
// GET /portal/mi-perfil — devuelve datos del usuario autenticado
func PortalGetMiPerfil(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
full, err := models.GetPortalUserByID(u.ID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "error al obtener perfil"})
}
return c.JSON(fiber.Map{
"id": full.ID,
"nombre": full.Nombre,
"email": full.Email,
"telefono": full.Telefono,
"indicativo": full.Indicativo,
"pais": full.Pais,
"ciudad": full.Ciudad,
"documento": full.Documento,
"empresa": full.Empresa,
"sitio_web": full.SitioWeb,
"documento_rut_file": full.DocumentoRutFile,
"documento_rut_nombre": full.DocumentoRutNombre,
})
}
// PUT /portal/mi-perfil — actualiza datos del usuario autenticado
func PortalUpdateMiPerfil(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
type Req struct {
Nombre string `json:"nombre"`
Email string `json:"email"`
Telefono string `json:"telefono"`
Indicativo string `json:"indicativo"`
Pais string `json:"pais"`
Ciudad string `json:"ciudad"`
Documento string `json:"documento"`
Empresa string `json:"empresa"`
SitioWeb string `json:"sitio_web"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"})
}
req.Nombre = strings.TrimSpace(req.Nombre)
req.Email = strings.TrimSpace(req.Email)
if req.Nombre == "" {
return c.Status(400).JSON(fiber.Map{"error": "El nombre es requerido"})
}
if req.Email == "" {
return c.Status(400).JSON(fiber.Map{"error": "El email es requerido"})
}
// Verificar que el email no lo use otro usuario
existing, err := models.GetPortalUserByEmail(req.Email)
if err == nil && existing.ID != u.ID {
return c.Status(400).JSON(fiber.Map{"error": "El email ya está en uso"})
}
full, err := models.GetPortalUserByID(u.ID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Usuario no encontrado"})
}
full.Nombre = req.Nombre
full.Email = req.Email
full.Telefono = req.Telefono
full.Indicativo = req.Indicativo
full.Pais = req.Pais
full.Ciudad = req.Ciudad
full.Documento = req.Documento
full.Empresa = req.Empresa
full.SitioWeb = req.SitioWeb
if err := models.UpdatePortalUser(full); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// PUT /portal/mi-perfil/password — cambia la contraseña del usuario autenticado
func PortalCambiarPassword(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
type Req struct {
Actual string `json:"actual"`
Nueva string `json:"nueva"`
Confirma string `json:"confirma"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Datos inválidos"})
}
if strings.TrimSpace(req.Nueva) == "" {
return c.Status(400).JSON(fiber.Map{"error": "La nueva contraseña no puede estar vacía"})
}
if req.Nueva != req.Confirma {
return c.Status(400).JSON(fiber.Map{"error": "Las contraseñas no coinciden"})
}
if len(req.Nueva) < 8 {
return c.Status(400).JSON(fiber.Map{"error": "La contraseña debe tener al menos 8 caracteres"})
}
full, err := models.GetPortalUserByID(u.ID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Usuario no encontrado"})
}
if match, matchErr := app.Http.Hash.Match(req.Actual, full.Password); matchErr != nil || !match {
return c.Status(400).JSON(fiber.Map{"error": "La contraseña actual es incorrecta"})
}
hashed, err := app.Http.Hash.Create(req.Nueva)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al procesar contraseña"})
}
if err := models.UpdatePortalUserPassword(u.ID, hashed); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// POST /portal/mi-perfil/rut-file — sube el documento RUT del usuario autenticado
func PortalSubirRutDocumento(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(401).JSON(fiber.Map{"error": "no autenticado"})
}
file, err := c.FormFile("rut_file")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"})
}
if file.Size > 10*1024*1024 {
return c.Status(400).JSON(fiber.Map{"error": "Máximo 10 MB"})
}
ext := strings.ToLower(filepath.Ext(file.Filename))
allowed := map[string]bool{".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true}
if !allowed[ext] {
return c.Status(400).JSON(fiber.Map{"error": "Solo se permiten PDF, JPG, PNG o WEBP"})
}
dir := fmt.Sprintf("uploads/portal_users/%d", u.ID)
if err := os.MkdirAll(dir, 0755); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"})
}
savePath := filepath.Join(dir, "rut"+ext)
if err := c.SaveFile(file, savePath); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"})
}
if err := models.UpdatePortalUserRutFile(u.ID, savePath, file.Filename); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "nombre": file.Filename})
}
// ─── Telegram portal ──────────────────────────────────────────────────────────
// PortalTelegramInit genera un código de vinculación temporal para el usuario del portal.
func PortalTelegramInit(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "No autenticado"})
}
token, err := models.GenerateTelegramPortalToken(u.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "No se pudo generar el código"})
}
// Obtener username del bot desde el primer TelegramConfig activo
configs, _ := models.GetAllTelegramConfigs()
botToken := ""
for _, cfg := range configs {
if cfg.Activo && cfg.BotToken != "" {
botToken = cfg.BotToken
break
}
}
botUsername := services.GetBotUsername(botToken)
return c.JSON(fiber.Map{
"token": token.Token,
"bot_username": botUsername,
"bot_link": "https://t.me/" + botUsername,
})
}
// PortalTelegramStatus devuelve si el usuario del portal tiene Telegram vinculado.
func PortalTelegramStatus(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "No autenticado"})
}
full, err := models.GetPortalUserByID(u.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"linked": full.TelegramChatID != "",
"chat_id": full.TelegramChatID,
})
}
// TelegramPortalWebhook recibe actualizaciones del bot de Telegram y vincula el chat_id
// al usuario del portal que envió el código de verificación.
// Este endpoint debe estar registrado como webhook en el bot: POST /setWebhook?url=.../webhooks/telegram-portal
func TelegramPortalWebhook(c *fiber.Ctx) error {
var update struct {
Message struct {
Chat struct {
ID int64 `json:"id"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
}
if err := c.BodyParser(&update); err != nil {
return c.SendStatus(fiber.StatusOK)
}
chatID := update.Message.Chat.ID
text := strings.TrimSpace(update.Message.Text)
log.Printf("[TelegramPortalWebhook] chatID=%d text=%q", chatID, text)
if chatID == 0 || text == "" {
return c.SendStatus(fiber.StatusOK)
}
// Buscar token en el texto: 6 caracteres hex mayúsculas
// Acepta "/vincular A3F9B2" o solo "A3F9B2"
var token string
for _, part := range strings.Fields(text) {
candidate := strings.ToUpper(strings.TrimPrefix(strings.TrimPrefix(part, "/vincular"), "/VINCULAR"))
candidate = strings.TrimSpace(candidate)
if len(candidate) == 6 {
token = candidate
break
}
}
if token == "" {
return c.SendStatus(fiber.StatusOK)
}
t, err := models.FindTelegramPortalToken(token)
if err != nil {
portalTelegramReply(chatID, "❌ Código inválido o expirado. Genera un nuevo código desde el portal.")
return c.SendStatus(fiber.StatusOK)
}
if err := models.UpdatePortalUserTelegramChatID(t.PortalUserID, fmt.Sprintf("%d", chatID)); err != nil {
return c.SendStatus(fiber.StatusOK)
}
models.DeleteTelegramPortalToken(t.PortalUserID)
portalTelegramReply(chatID, "✅ ¡Tu Telegram ha sido vinculado al portal correctamente!\n\nRecibirás notificaciones importantes por este medio.")
return c.SendStatus(fiber.StatusOK)
}
// portalTelegramReply envía un mensaje usando el primer bot activo configurado.
func portalTelegramReply(chatID int64, text string) {
configs, _ := models.GetAllTelegramConfigs()
for _, cfg := range configs {
if cfg.Activo && cfg.BotToken != "" {
svc := &services.TelegramService{BotToken: cfg.BotToken}
_ = svc.SendMessage(chatID, text)
return
}
}
}
// PortalTelegramValidar llama getUpdates en todos los bots activos, busca el mensaje
// "/vincular TOKEN" del usuario y vincula el chat_id si coincide.
// POST /portal/mi-perfil/telegram-validar
func PortalTelegramValidar(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "No autenticado"})
}
// Recuperar el token vigente del usuario
tkn, err := models.GetTelegramPortalTokenByUser(u.ID)
if err != nil || tkn == nil {
return c.Status(400).JSON(fiber.Map{"error": "No tienes un código activo. Genera uno primero."})
}
configs, _ := models.GetAllTelegramConfigs()
for _, cfg := range configs {
if !cfg.Activo || cfg.BotToken == "" {
continue
}
chatID, found := searchTokenInUpdates(cfg.BotToken, tkn.Token)
if !found {
continue
}
// Vincular
if err := models.UpdatePortalUserTelegramChatID(u.ID, fmt.Sprintf("%d", chatID)); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "No se pudo vincular. Intenta de nuevo."})
}
models.DeleteTelegramPortalToken(u.ID)
svc := &services.TelegramService{BotToken: cfg.BotToken}
_ = svc.SendMessage(chatID, "✅ ¡Tu Telegram ha sido vinculado al portal correctamente!\n\nRecibirás notificaciones importantes por este medio.")
log.Printf("[PortalTelegramValidar] usuario=%d chatID=%d vinculado", u.ID, chatID)
return c.JSON(fiber.Map{"ok": true, "chat_id": fmt.Sprintf("%d", chatID)})
}
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "No se encontró el mensaje. Asegúrate de enviar /vincular " + tkn.Token + " al bot."})
}
// searchTokenInUpdates llama getUpdates y busca un mensaje que contenga el token.
// Retorna el chat_id y true si lo encuentra.
func searchTokenInUpdates(botToken, token string) (int64, bool) {
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?limit=100", botToken)
resp, err := http.Get(apiURL) //nolint:noctx
if err != nil {
log.Printf("[searchTokenInUpdates] error getUpdates: %v", err)
return 0, false
}
defer resp.Body.Close()
var result struct {
OK bool `json:"ok"`
Result []struct {
Message struct {
Chat struct {
ID int64 `json:"id"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || !result.OK {
return 0, false
}
upperToken := strings.ToUpper(token)
for _, upd := range result.Result {
txt := strings.ToUpper(strings.TrimSpace(upd.Message.Text))
if strings.Contains(txt, upperToken) && upd.Message.Chat.ID != 0 {
log.Printf("[searchTokenInUpdates] token=%s chatID=%d", token, upd.Message.Chat.ID)
return upd.Message.Chat.ID, true
}
}
return 0, false
}
// PortalDescargarCronograma entrega el cronograma del proyecto como .xlsx,
// para que el cliente pueda mandárselo a alguien que no entra al portal.
// Ruta: GET /portal/proyecto/:slug/cronograma.xlsx
func PortalDescargarCronograma(c *fiber.Ctx) error {
u := middlewares.PortalUserFromLocals(c)
if u == nil {
return c.Redirect("/portal/login")
}
proy, err := models.GetProyectoBySlug(c.Params("slug"))
if err != nil {
return c.Status(404).SendString("proyecto no encontrado")
}
// Mismo chequeo de acceso que PortalProyecto: sin esto, cualquiera con
// sesión de portal podría bajarse el cronograma de otro cliente
// adivinando el slug.
fullUser, _ := models.GetPortalUserByID(u.ID)
permitido := false
for _, cid := range models.GetClienteIDsForPortalUser(fullUser) {
if cid == proy.ClienteID {
permitido = true
break
}
}
if !permitido {
return c.Status(403).SendString("sin acceso a este proyecto")
}
fases, _ := models.GetFasesByProyecto(proy.ID)
estados := map[string]string{
"pendiente": "Pendiente",
"en_progreso": "En progreso",
"completado": "Completado",
}
fecha := func(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("02/01/2006")
}
filas := [][]string{{"#", "Fase", "Descripción", "Estado", "Fecha estimada", "Fecha completado", "Entregables"}}
for i, f := range fases {
f.ParseEntregables()
estado := estados[f.Estado]
if estado == "" {
estado = f.Estado
}
filas = append(filas, []string{
strconv.Itoa(i + 1),
f.Nombre,
f.Descripcion,
estado,
fecha(f.FechaEstimada),
fecha(f.FechaCompletado),
strings.Join(f.Entregables, ", "),
})
}
nombre := fmt.Sprintf("cronograma-%s-%s.xlsx", proy.Slug, time.Now().Format("2006-01-02"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", `attachment; filename="`+nombre+`"`)
var buf bytes.Buffer
if err := services.EscribirXLSX(&buf, "Cronograma", filas); err != nil {
return c.Status(500).SendString("no se pudo generar el archivo")
}
return c.Send(buf.Bytes())
}