Files
soft_usite/rest/controllers/portal_controller.go
T
Lizandro GuarnizoandCopilot 6e27032ac6 feat(portal): agregar flujo de recuperación de contraseña
- Nuevo modelo PortalPasswordResetToken con token seguro (32 bytes, 1h vigencia)
- AutoMigrate del nuevo modelo en main.go
- SendPortalPasswordResetEmail con diseño consistente al app
- Handlers: PortalForgotPasswordPage/Post y PortalResetPasswordPost/Page
- Rutas públicas GET/POST /portal/forgot-password y /portal/reset-password
- Vistas forgot_password.html y reset_password.html con validación JS
- Enlace ¿Olvidaste tu contraseña? en login.html + soporte mensaje success

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 15:25:53 -05:00

890 lines
30 KiB
Go

package controllers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"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"),
"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, _ := 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)
facturasNoLeidas := models.CountUnreadFacturaNotifs(u.ID)
documentos, _ := models.GetDocumentosByProyecto(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 != 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)
}
// 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 != 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
}