Files
soft_usite/rest/controllers/portal_controller.go
T
Lizandro GuarnizoandClaude Sonnet 5 cfa91a5c09 fix(portal): el avance del dashboard mostraba siempre el valor anterior
Auditoría de /portal/dashboard. Cuatro problemas, todos en el mismo bucle:

1. El avance se veía un render atrasado. ActualizarProgresoProyecto corría
   DESPUÉS de cargar los proyectos: escribía el valor nuevo en la base pero
   los structs ya cargados seguían con el viejo, que es lo que se renderiza.
   El usuario veía el cálculo de la visita anterior.

2. N+1 con escrituras en un GET: dos COUNT y un UPDATE por proyecto. Con 10
   proyectos, 30 consultas y 10 escrituras por cada carga del dashboard —
   incluyendo las de cualquier bot que pase. Ahora es UNA consulta agrupada
   y ninguna escritura; los caminos que tocan una fase ya mantienen la
   columna al día, así que recalcular en el GET no aportaba nada.

3. Orden aleatorio de los grupos: se recorría un map de Go, así que un
   partner veía sus clientes en distinto orden en cada recarga.

4. isPartner se decidía con u.Rol mientras el alcance se decidía con
   Role.EsPortalPartner. Desincronizados, un usuario veía proyectos de
   varios clientes sin agrupar, o la vista agrupada vacía. Ahora hay una
   sola definición (PortalUser.EsPartner) con test de los dos sentidos.

Además, el error de carga se descartaba con `_` y el usuario terminaba
viendo "no tenés proyectos", indistinguible de una caída de la base.

Sin hallazgos de seguridad: el chequeo de acceso por cliente está en todas
las rutas, la sesión revalida Activo en cada request, y las plantillas no
usan x-html ni template.HTML, así que Go escapa todo.

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

993 lines
33 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 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(),
}, "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())
}