509 lines
16 KiB
Go
509 lines
16 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"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"),
|
|
}, "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")
|
|
}
|
|
|
|
// ─── 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, _ := models.GetProyectosByClienteIDs(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)
|
|
}
|
|
|
|
return c.Render("portal/dashboard", fiber.Map{
|
|
"portalUser": fullUser,
|
|
"proyectos": proyectos,
|
|
"grupos": grupos,
|
|
"isPartner": fullUser.Rol == "partner",
|
|
}, "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.GetTicketsByPortalUser(u.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, _ := models.GetProyectosByClienteIDs(clienteIDs)
|
|
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.GetTicketsByPortalUser(u.ID)
|
|
facturas, _ := models.GetFacturasByCliente(proy.ClienteID, true)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"proyecto": proy,
|
|
"fases": fases,
|
|
"avances": avances,
|
|
"entregables": entregables,
|
|
"tickets": tickets,
|
|
"facturas": facturas,
|
|
})
|
|
}
|
|
|
|
// 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 != 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 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)
|
|
}
|
|
|
|
// ─── 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 != 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,
|
|
"documento": full.Documento,
|
|
"empresa": full.Empresa,
|
|
})
|
|
}
|
|
|
|
// 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"`
|
|
Documento string `json:"documento"`
|
|
Empresa string `json:"empresa"`
|
|
}
|
|
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.Documento = req.Documento
|
|
full.Empresa = req.Empresa
|
|
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})
|
|
}
|