From 28affb5a55b2bcf77e171f1354ba7e1eece89d19 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:39:03 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=C3=A1rea=20de=20soporte=20con=20we?= =?UTF-8?q?bhook=20de=20correo=20y=20asignaci=C3=B3n=20de=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 2 + pkg/models/proyecto_ticket.go | 35 ++- pkg/models/proyecto_ticket_test.go | 91 +++++++ pkg/models/soporte_webhook.go | 39 +++ pkg/models/soporte_webhook_test.go | 58 +++++ pkg/services/notif_dispatch.go | 24 +- pkg/services/soporte_service.go | 85 +++++++ pkg/services/soporte_service_test.go | 105 ++++++++ resources/views/soporte_webhook.html | 120 +++++++++ resources/views/tickets_admin.html | 60 +++-- rest/controllers/portal_controller.go | 14 +- rest/controllers/proyecto_controller.go | 6 +- rest/controllers/soporte_controller.go | 261 ++++++++++++++++++++ rest/controllers/soporte_controller_test.go | 80 ++++++ rest/routes/publicas.go | 4 + rest/routes/user.go | 7 + 16 files changed, 957 insertions(+), 34 deletions(-) create mode 100644 pkg/models/proyecto_ticket_test.go create mode 100644 pkg/models/soporte_webhook.go create mode 100644 pkg/models/soporte_webhook_test.go create mode 100644 pkg/services/soporte_service.go create mode 100644 pkg/services/soporte_service_test.go create mode 100644 resources/views/soporte_webhook.html create mode 100644 rest/controllers/soporte_controller.go create mode 100644 rest/controllers/soporte_controller_test.go diff --git a/main.go b/main.go index 241bae2..b2cc70e 100755 --- a/main.go +++ b/main.go @@ -118,6 +118,8 @@ func main() { // Tablero de tareas &models.Tarea{}, &models.TareaComentario{}, + // Soporte: webhook de correo entrante + &models.SoporteWebhookConfig{}, ) // Seed automático (idempotente) de módulos del sistema migrations.SeedRenovaciones() diff --git a/pkg/models/proyecto_ticket.go b/pkg/models/proyecto_ticket.go index a9a7b62..b8f5062 100644 --- a/pkg/models/proyecto_ticket.go +++ b/pkg/models/proyecto_ticket.go @@ -9,13 +9,17 @@ import ( type ProyectoTicket struct { gorm.Model - ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"` - PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"` + ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"` + PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"` AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"` + EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"` Titulo string `json:"titulo" gorm:"column:titulo"` Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente + Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email + AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"` + Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"` Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"` } @@ -39,13 +43,13 @@ func (TicketMensaje) TableName() string { return "ticket_mensajes" } func GetTicketsByProyecto(proyectoID uint) ([]ProyectoTicket, error) { var items []ProyectoTicket err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID). - Preload("Mensajes").Order("created_at DESC").Find(&items).Error + Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error return items, err } func GetTicketByID(id uint) (*ProyectoTicket, error) { var item ProyectoTicket - err := app.Http.Database.DB.Preload("Mensajes").First(&item, id).Error + err := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").First(&item, id).Error return &item, err } @@ -65,13 +69,13 @@ func CreateTicketMensaje(m *TicketMensaje) error { func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) { var items []ProyectoTicket err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID). - Preload("Mensajes").Order("created_at DESC").Find(&items).Error + Preload("Mensajes").Preload("Asignado").Order("created_at DESC").Find(&items).Error return items, err } func GetAllTickets(estado string) ([]ProyectoTicket, error) { var items []ProyectoTicket - db := app.Http.Database.DB.Preload("Mensajes").Order("created_at DESC") + db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Order("created_at DESC") if estado != "" && estado != "todos" { db = db.Where("estado = ?", estado) } @@ -79,6 +83,25 @@ func GetAllTickets(estado string) ([]ProyectoTicket, error) { return items, err } +func AssignedToUser(userID uint) error { + return app.Http.Database.DB.Model(&ProyectoTicket{}). + Where("asignado_a IS NULL OR asignado_a = 0"). + Update("asignado_a", userID).Error +} + +func CountTicketsByEstado() map[string]int64 { + result := map[string]int64{} + type row struct{ Estado string; Count int64 } + var rows []row + app.Http.Database.DB.Model(&ProyectoTicket{}). + Select("estado, count(*) as count"). + Group("estado").Find(&rows) + for _, r := range rows { + result[r.Estado] = r.Count + } + return result +} + // MarkTicketMessagesReadByPortal marca como leídos todos los mensajes de admin // de un ticket para el portal user (cuando abre el ticket). func MarkTicketMessagesReadByPortal(ticketID uint) error { diff --git a/pkg/models/proyecto_ticket_test.go b/pkg/models/proyecto_ticket_test.go new file mode 100644 index 0000000..5a1f45d --- /dev/null +++ b/pkg/models/proyecto_ticket_test.go @@ -0,0 +1,91 @@ +package models + +import ( + "testing" +) + +func TestProyectoTicketTableName(t *testing.T) { + if got := (ProyectoTicket{}).TableName(); got != "proyecto_tickets" { + t.Errorf("TableName() = %q, want %q", got, "proyecto_tickets") + } +} + +func TestTicketMensajeTableName(t *testing.T) { + if got := (TicketMensaje{}).TableName(); got != "ticket_mensajes" { + t.Errorf("TableName() = %q, want %q", got, "ticket_mensajes") + } +} + +func TestProyectoTicketDefaults(t *testing.T) { + ticket := ProyectoTicket{ + Titulo: "Test ticket", + Descripcion: "Descripción", + AutorNombre: "Cliente", + } + if ticket.Estado != "" { + t.Errorf("Estado debe ser vacío por defecto (GORM default), got %q", ticket.Estado) + } + if ticket.Prioridad != "" { + t.Errorf("Prioridad debe ser vacía por defecto (GORM default), got %q", ticket.Prioridad) + } + if ticket.Origen != "" { + t.Errorf("Origen debe ser vacío por defecto (GORM default), got %q", ticket.Origen) + } + if ticket.ProyectoID != nil { + t.Errorf("ProyectoID debe ser nil por defecto, got %v", ticket.ProyectoID) + } + if ticket.PortalUserID != nil { + t.Errorf("PortalUserID debe ser nil por defecto, got %v", ticket.PortalUserID) + } +} + +func TestProyectoTicketEmailFrom(t *testing.T) { + ticket := ProyectoTicket{ + EmailFrom: "cliente@ejemplo.com", + AutorNombre: "Cliente", + Titulo: "Soporte", + Origen: "email", + } + if ticket.EmailFrom != "cliente@ejemplo.com" { + t.Errorf("EmailFrom = %q, want %q", ticket.EmailFrom, "cliente@ejemplo.com") + } + if ticket.Origen != "email" { + t.Errorf("Origen = %q, want %q", ticket.Origen, "email") + } +} + +func TestProyectoTicketAsignado(t *testing.T) { + uid := uint(42) + ticket := ProyectoTicket{ + AsignadoA: &uid, + } + if ticket.AsignadoA == nil { + t.Fatal("AsignadoA debe ser no nil") + } + if *ticket.AsignadoA != 42 { + t.Errorf("AsignadoA = %d, want %d", *ticket.AsignadoA, 42) + } +} + +func TestProyectoTicketAsignadoNil(t *testing.T) { + ticket := ProyectoTicket{} + if ticket.AsignadoA != nil { + t.Errorf("AsignadoA debe ser nil por defecto, got %v", *ticket.AsignadoA) + } +} + +func TestTicketMensajeDefaults(t *testing.T) { + msg := TicketMensaje{ + TicketID: 1, + Contenido: "Hola", + AutorNombre: "Admin", + } + if msg.EsAdmin { + t.Error("EsAdmin debe ser false por defecto") + } + if msg.LeidoPortal { + t.Error("LeidoPortal debe ser false por defecto") + } +} + +// CountTicketsByEstado requiere DB real — probado en integration tests diff --git a/pkg/models/soporte_webhook.go b/pkg/models/soporte_webhook.go new file mode 100644 index 0000000..a5fcc68 --- /dev/null +++ b/pkg/models/soporte_webhook.go @@ -0,0 +1,39 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +type SoporteWebhookConfig struct { + gorm.Model + Activo bool `json:"activo" gorm:"column:activo;default:true"` + Nombre string `json:"nombre" gorm:"column:nombre;size:100"` + Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic + ApiKey string `json:"api_key" gorm:"column:api_key;size:255"` + EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app + ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"` + MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"` + AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user +} + +func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" } + +func GetSoporteWebhookActivo() (*SoporteWebhookConfig, error) { + var item SoporteWebhookConfig + err := app.Http.Database.DB.Where("activo = ?", true).First(&item).Error + return &item, err +} + +func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) { + var items []SoporteWebhookConfig + err := app.Http.Database.DB.Order("created_at DESC").Find(&items).Error + return items, err +} + +func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error { + if s.ID > 0 { + return app.Http.Database.DB.Model(s).Updates(s).Error + } + return app.Http.Database.DB.Create(s).Error +} diff --git a/pkg/models/soporte_webhook_test.go b/pkg/models/soporte_webhook_test.go new file mode 100644 index 0000000..b2687b6 --- /dev/null +++ b/pkg/models/soporte_webhook_test.go @@ -0,0 +1,58 @@ +package models + +import ( + "testing" +) + +func TestSoporteWebhookConfigTableName(t *testing.T) { + if got := (SoporteWebhookConfig{}).TableName(); got != "soporte_webhook_config" { + t.Errorf("TableName() = %q, want %q", got, "soporte_webhook_config") + } +} + +func TestSoporteWebhookConfigDefaults(t *testing.T) { + cfg := SoporteWebhookConfig{ + Nombre: "Test", + Provider: "sendgrid", + } + if cfg.Provider != "sendgrid" { + t.Errorf("Provider = %q, want %q", cfg.Provider, "sendgrid") + } + if cfg.EmailDestino != "" { + t.Errorf("EmailDestino debe ser vacío, got %q", cfg.EmailDestino) + } +} + +func TestSoporteWebhookConfigAutoResponder(t *testing.T) { + cfg := SoporteWebhookConfig{ + Nombre: "Soporte", + ResponderAuto: true, + MensajeAuto: "Gracias por contactarnos", + } + if !cfg.ResponderAuto { + t.Error("ResponderAuto debe ser true") + } + if cfg.MensajeAuto != "Gracias por contactarnos" { + t.Errorf("MensajeAuto = %q, want %q", cfg.MensajeAuto, "Gracias por contactarnos") + } +} + +func TestSoporteWebhookConfigAsignarA(t *testing.T) { + uid := uint(5) + cfg := SoporteWebhookConfig{ + AsignarA: &uid, + } + if cfg.AsignarA == nil { + t.Fatal("AsignarA debe ser no nil") + } + if *cfg.AsignarA != 5 { + t.Errorf("AsignarA = %d, want %d", *cfg.AsignarA, 5) + } +} + +func TestSoporteWebhookConfigAsignarANil(t *testing.T) { + cfg := SoporteWebhookConfig{} + if cfg.AsignarA != nil { + t.Errorf("AsignarA debe ser nil por defecto, got %v", *cfg.AsignarA) + } +} diff --git a/pkg/services/notif_dispatch.go b/pkg/services/notif_dispatch.go index 74da1da..dd26e9e 100644 --- a/pkg/services/notif_dispatch.go +++ b/pkg/services/notif_dispatch.go @@ -95,9 +95,25 @@ func DispatchTicketRespuestaAdmin(ticket *models.ProyectoTicket, contenido strin if ticket == nil { return } - portalUser, err := models.GetPortalUserByID(ticket.PortalUserID) + proyectoSlug := "" + if ticket.ProyectoID != nil { + if proy, err := models.GetProyectoByID(*ticket.ProyectoID); err == nil { + proyectoSlug = proy.Slug + proyectoNombre = proy.Nombre + } + } + + // Si no está asociado a un portal user (ej: ticket por email), responder por email directo + if ticket.PortalUserID == nil { + if ticket.EmailFrom != "" { + go SendTicketRespuestaPortalUser(ticket.EmailFrom, ticket.AutorNombre, proyectoNombre, ticket.Titulo, contenido, "") + } + return + } + + portalUser, err := models.GetPortalUserByID(*ticket.PortalUserID) if err != nil || portalUser == nil { - log.Printf("[Notif] PortalUser %d no encontrado: %v", ticket.PortalUserID, err) + log.Printf("[Notif] PortalUser %d no encontrado: %v", *ticket.PortalUserID, err) return } @@ -106,10 +122,6 @@ func DispatchTicketRespuestaAdmin(ticket *models.ProyectoTicket, contenido strin return } - proyectoSlug := "" - if proy, err := models.GetProyectoByID(ticket.ProyectoID); err == nil { - proyectoSlug = proy.Slug - } portalPath := portalTicketPath(proyectoSlug, ticket.ID) portalURL := absAppURL(portalPath) titulo := fmt.Sprintf("Respuesta en tu ticket: %s", ticket.Titulo) diff --git a/pkg/services/soporte_service.go b/pkg/services/soporte_service.go new file mode 100644 index 0000000..d3bd4a6 --- /dev/null +++ b/pkg/services/soporte_service.go @@ -0,0 +1,85 @@ +package services + +import ( + "fmt" + "log" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// SendSoporteAutoRespuesta envía acuse de recibo automático al crear un ticket por email +func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) { + if ticket == nil || ticket.EmailFrom == "" { + return + } + subject := fmt.Sprintf("Recibimos tu solicitud: %s", ticket.Titulo) + mensaje := "Hemos recibido tu solicitud y te responderemos a la brevedad." + if app.Http.Database.DB != nil { + if cfg, err := models.GetSoporteWebhookActivo(); err == nil && cfg.MensajeAuto != "" { + mensaje = cfg.MensajeAuto + } + } + htmlBody := fmt.Sprintf(` + +
+
+
U
+

Ticket recibido

+

Soporte U-site

+
+

Hola %s,

+

%s

+
+

Ticket: #%d

+

Asunto: %s

+

Mensaje: %s

+
+

Este es un mensaje automático. No respondas a este correo.

+
`, ticket.AutorNombre, mensaje, ticket.ID, ticket.Titulo, ticket.Descripcion) + + go func() { + if err := app.Http.Mail.Send(ticket.EmailFrom, subject, htmlBody); err != nil { + log.Printf("[Soporte] Error enviando auto-respuesta a %s: %v", ticket.EmailFrom, err) + } else { + log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID) + } + }() +} + +// SendSoporteNotifAdmin notifica a los admins sobre un nuevo ticket de email +func SendSoporteNotifAdmin(ticket *models.ProyectoTicket) { + if ticket == nil { + return + } + adminEmail := "" + if app.Http.Database.DB != nil { + if cfg, err := models.GetSmtpConfig(); err == nil { + adminEmail = cfg.FromAddress + } + } + if adminEmail == "" { + return + } + subject := fmt.Sprintf("🎫 Nuevo ticket por email: %s", ticket.Titulo) + htmlBody := fmt.Sprintf(` + +
+
+
🎫
+

Nuevo ticket por email

+

De: %s <%s>

+
+
+

Asunto: %s

+

Mensaje: %s

+
+ Ver ticket +
`, ticket.AutorNombre, ticket.EmailFrom, ticket.Titulo, ticket.Descripcion, absAppURL(""), ticket.ID) + + go func() { + if err := app.Http.Mail.Send(adminEmail, subject, htmlBody); err != nil { + log.Printf("[Soporte] Error notificando admin: %v", err) + } + }() +} diff --git a/pkg/services/soporte_service_test.go b/pkg/services/soporte_service_test.go new file mode 100644 index 0000000..35c7c8e --- /dev/null +++ b/pkg/services/soporte_service_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "testing" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/config" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +func setupTestApp() { + if app.Http == nil { + app.Http = &config.AppConfig{ + Server: config.ServerConfig{ + Url: "http://localhost", + Port: "8080", + }, + } + } +} + +func TestSendSoporteAutoRespuestaNilTicket(t *testing.T) { + setupTestApp() + // No debe panic si ticket es nil + SendSoporteAutoRespuesta(nil) +} + +func TestSendSoporteAutoRespuestaSinEmail(t *testing.T) { + setupTestApp() + ticket := &models.ProyectoTicket{ + Titulo: "Test", + Descripcion: "Desc", + AutorNombre: "Cliente", + Origen: "email", + } + // No debe hacer nada si no tiene email + SendSoporteAutoRespuesta(ticket) +} + +func TestSendSoporteAutoRespuestaConEmail(t *testing.T) { + setupTestApp() + ticket := &models.ProyectoTicket{ + EmailFrom: "cliente@test.com", + Titulo: "Ayuda con factura", + Descripcion: "No puedo ver mi factura de enero", + AutorNombre: "Juan Pérez", + Origen: "email", + } + app.Http.Mail.FromAddress = "soporte@u-s.app" + app.Http.Mail.Host = "127.0.0.1" + app.Http.Mail.Port = 2525 + // Solo verificar que no panic (enviaría a un SMTP que no existe, pero el go routine captura error) + SendSoporteAutoRespuesta(ticket) +} + +func TestSendSoporteNotifAdminNil(t *testing.T) { + setupTestApp() + SendSoporteNotifAdmin(nil) +} + +func TestSendSoporteNotifAdminSinSmtp(t *testing.T) { + setupTestApp() + ticket := &models.ProyectoTicket{ + EmailFrom: "cliente@test.com", + Titulo: "Problema", + Descripcion: "No funciona", + AutorNombre: "Cliente", + } + // Sin SMTP config no debe panic + SendSoporteNotifAdmin(ticket) +} + +func TestSendSoporteAutoRespuestaOrigenPortal(t *testing.T) { + setupTestApp() + ticket := &models.ProyectoTicket{ + EmailFrom: "", + Titulo: "Portal ticket", + Descripcion: "Desc", + AutorNombre: "Portal User", + Origen: "portal", + } + // No debe intentar enviar email porque EmailFrom está vacío + SendSoporteAutoRespuesta(ticket) +} + +func TestSendSoporteNotifAdminConSmtp(t *testing.T) { + setupTestApp() + // Simular SMTP config en app.Http.Mail + app.Http.Mail.FromAddress = "admin@u-s.app" + app.Http.Mail.Host = "127.0.0.1" + app.Http.Mail.Port = 2525 + + ticket := &models.ProyectoTicket{ + // gorm.Model embedido: ID se asigna via Create en DB real + EmailFrom: "cliente@test.com", + Titulo: "Error en el sistema", + Descripcion: "Recibo un error 500", + AutorNombre: "María López", + Origen: "email", + } + // Verificar que no panic (el email se envía en goroutine) + SendSoporteNotifAdmin(ticket) +} diff --git a/resources/views/soporte_webhook.html b/resources/views/soporte_webhook.html new file mode 100644 index 0000000..8851dc8 --- /dev/null +++ b/resources/views/soporte_webhook.html @@ -0,0 +1,120 @@ +
+

Webhook de correo entrante

+

Configura la integración para recibir correos de soporte@u-s.app y convertirlos automáticamente en tickets.

+ +
+
+ + +
+ +
+ + +

Correo al que llegarán los mensajes. Ej: soporte@u-s.app

+
+ +
+ + +

Si el proveedor envía un token de verificación, pégalo aquí.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +

Los tickets creados por email se asignarán automáticamente a este admin.

+
+ +
+ +
+ +
+

URL del webhook

+

+

Configura esta URL en el proveedor de correo para enviar los emails entrantes.

+
+
+
+ + diff --git a/resources/views/tickets_admin.html b/resources/views/tickets_admin.html index f53198c..4571ded 100644 --- a/resources/views/tickets_admin.html +++ b/resources/views/tickets_admin.html @@ -3,9 +3,8 @@

Tickets de soporte

-

Todos los tickets abiertos por clientes del portal

+

Todos los tickets abiertos por clientes del portal y correo

-
+ +
+ Mostrando tickets +