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>
999 lines
34 KiB
Go
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())
|
|
}
|