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()) }