feat: área de soporte con webhook de correo y asignación de tickets
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(`<!DOCTYPE html>
|
||||
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
|
||||
<div style="text-align:center;margin-bottom:24px">
|
||||
<div style="width:48px;height:48px;border-radius:50%%;background:#8eb02f;display:inline-flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:20px">U</div>
|
||||
<h2 style="margin:12px 0 4px;color:#1e293b">Ticket recibido</h2>
|
||||
<p style="color:#64748b;font-size:14px;margin:0">Soporte U-site</p>
|
||||
</div>
|
||||
<p style="color:#334155;font-size:15px">Hola <strong>%s</strong>,</p>
|
||||
<p style="color:#334155;font-size:14px">%s</p>
|
||||
<div style="background:#f8fafc;border-radius:10px;padding:16px;margin:20px 0;border:1px solid #e2e8f0">
|
||||
<p style="margin:0 0 6px;font-size:13px;color:#64748b"><strong>Ticket:</strong> #%d</p>
|
||||
<p style="margin:0 0 6px;font-size:13px;color:#64748b"><strong>Asunto:</strong> %s</p>
|
||||
<p style="margin:0;font-size:13px;color:#64748b"><strong>Mensaje:</strong> %s</p>
|
||||
</div>
|
||||
<p style="color:#94a3b8;font-size:12px">Este es un mensaje automático. No respondas a este correo.</p>
|
||||
</div></body></html>`, 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(`<!DOCTYPE html>
|
||||
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
|
||||
<div style="width:40px;height:40px;border-radius:50%%;background:#8eb02f;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px">🎫</div>
|
||||
<div><h2 style="margin:0;color:#1e293b;font-size:17px">Nuevo ticket por email</h2>
|
||||
<p style="margin:0;color:#64748b;font-size:13px">De: <strong>%s</strong> <%s></p></div>
|
||||
</div>
|
||||
<div style="background:#f8fafc;border-radius:10px;padding:16px;margin-bottom:20px;border:1px solid #e2e8f0">
|
||||
<p style="margin:0 0 6px;font-size:14px;color:#334155"><strong>Asunto:</strong> %s</p>
|
||||
<p style="margin:0;font-size:14px;color:#334155"><strong>Mensaje:</strong> %s</p>
|
||||
</div>
|
||||
<a href="%s/app/tickets?ticket=%d" style="display:block;text-align:center;background:#8eb02f;color:#fff;padding:12px 24px;border-radius:10px;font-weight:600;text-decoration:none;font-size:15px">Ver ticket</a>
|
||||
</div></body></html>`, 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<div x-data="soporteWebhook()" x-init="init()" class="p-6 max-w-2xl">
|
||||
<h1 class="text-xl font-bold text-slate-800 mb-1">Webhook de correo entrante</h1>
|
||||
<p class="text-sm text-slate-500 mb-6">Configura la integración para recibir correos de soporte@u-s.app y convertirlos automáticamente en tickets.</p>
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Proveedor</label>
|
||||
<select x-model="cfg.provider" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<option value="sendgrid">SendGrid</option>
|
||||
<option value="mailgun">Mailgun</option>
|
||||
<option value="generic">Genérico</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Email destino</label>
|
||||
<input x-model="cfg.email_destino" type="text" placeholder="soporte@u-s.app"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<p class="text-xs text-slate-400 mt-1">Correo al que llegarán los mensajes. Ej: soporte@u-s.app</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">API Key / Secreto</label>
|
||||
<input x-model="cfg.api_key" type="text" placeholder="opcional para validación"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<p class="text-xs text-slate-400 mt-1">Si el proveedor envía un token de verificación, pégalo aquí.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input x-model="cfg.responder_auto" type="checkbox" id="resp-auto"
|
||||
class="rounded border-slate-300 text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<label for="resp-auto" class="text-sm text-slate-700">Responder automáticamente con acuse de recibo</label>
|
||||
</div>
|
||||
|
||||
<div x-show="cfg.responder_auto">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Mensaje de auto-respuesta</label>
|
||||
<textarea x-model="cfg.mensaje_auto" rows="3" placeholder="Hemos recibido tu solicitud y te responderemos a la brevedad."
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Auto-asignar a</label>
|
||||
<select x-model="cfg.asignar_a" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<option value="">Sin asignar</option>
|
||||
<template x-for="a in admins" :key="a.id">
|
||||
<option :value="a.id" x-text="a.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="text-xs text-slate-400 mt-1">Los tickets creados por email se asignarán automáticamente a este admin.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<button @click="guardar()"
|
||||
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||
style="background:#8eb02f"
|
||||
onmouseover="this.style.background='#6d8c24'" onmouseout="this.style.background='#8eb02f'">
|
||||
Guardar configuración
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="webhookUrl" class="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
|
||||
<p class="text-xs font-medium text-slate-600 mb-1">URL del webhook</p>
|
||||
<p class="text-sm text-slate-800 font-mono break-all" x-text="webhookUrl"></p>
|
||||
<p class="text-xs text-slate-400 mt-1">Configura esta URL en el proveedor de correo para enviar los emails entrantes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function soporteWebhook() {
|
||||
return {
|
||||
cfg: {
|
||||
id: 0,
|
||||
nombre: 'Soporte',
|
||||
provider: 'sendgrid',
|
||||
email_destino: 'soporte@u-s.app',
|
||||
api_key: '',
|
||||
responder_auto: true,
|
||||
mensaje_auto: '',
|
||||
asignar_a: '',
|
||||
},
|
||||
admins: [],
|
||||
webhookUrl: '',
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const r = await axios.get('/app/soporte/webhook/data');
|
||||
if (r.data && r.data.data) {
|
||||
this.cfg = { ...this.cfg, ...r.data.data };
|
||||
this.cfg.asignar_a = r.data.data.asignar_a || '';
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const r = await axios.get('/app/tickets/admins');
|
||||
this.admins = r.data || [];
|
||||
} catch {}
|
||||
this.webhookUrl = window.location.origin + '/webhooks/soporte/' + this.cfg.provider;
|
||||
},
|
||||
|
||||
async guardar() {
|
||||
const payload = {
|
||||
id: this.cfg.id || 0,
|
||||
nombre: this.cfg.nombre || 'Soporte',
|
||||
provider: this.cfg.provider,
|
||||
email_destino: this.cfg.email_destino,
|
||||
api_key: this.cfg.api_key,
|
||||
responder_auto: this.cfg.responder_auto,
|
||||
mensaje_auto: this.cfg.mensaje_auto,
|
||||
asignar_a: this.cfg.asignar_a ? parseInt(this.cfg.asignar_a) : null,
|
||||
};
|
||||
try {
|
||||
await axios.post('/app/soporte/webhook', payload);
|
||||
alert('Configuración guardada');
|
||||
} catch (e) {
|
||||
alert('Error al guardar: ' + (e.response?.data?.error || e.message));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -3,9 +3,8 @@
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-slate-800">Tickets de soporte</h1>
|
||||
<p class="text-sm text-slate-500 mt-0.5">Todos los tickets abiertos por clientes del portal</p>
|
||||
<p class="text-sm text-slate-500 mt-0.5">Todos los tickets abiertos por clientes del portal y correo</p>
|
||||
</div>
|
||||
<!-- Filtro estado -->
|
||||
<div class="flex items-center gap-2">
|
||||
<template x-for="s in estados" :key="s.val">
|
||||
<button @click="filtro=s.val; load()"
|
||||
@@ -32,25 +31,29 @@
|
||||
<div :id="'ticket-' + t.ID" class="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<!-- Cabecera del ticket -->
|
||||
<div class="p-4 flex items-start gap-4 cursor-pointer" @click="t._open = !t._open">
|
||||
<!-- Indicador de estado -->
|
||||
<div class="mt-0.5 w-2.5 h-2.5 rounded-full flex-shrink-0" :class="dotClass(t.estado)"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span class="badge" :class="ticketBadge(t.estado)" x-text="t.estado"></span>
|
||||
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
|
||||
<span x-show="t.proyecto && t.proyecto.nombre" class="text-xs text-slate-400">
|
||||
📁 <span x-text="t.proyecto ? t.proyecto.nombre : ''"></span>
|
||||
</span>
|
||||
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
|
||||
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
Por: <span x-text="t.autor_nombre"></span> · <span x-text="formatDate(t.CreatedAt)"></span>
|
||||
<span x-text="t.autor_nombre"></span>
|
||||
<span x-show="t.email_from" class="text-slate-400"><<span x-text="t.email_from"></span>></span>
|
||||
<span class="mx-1">·</span>
|
||||
<span x-text="formatDate(t.CreatedAt)"></span>
|
||||
<span x-show="t.mensajes && t.mensajes.length" class="ml-2 text-slate-400">
|
||||
💬 <span x-text="t.mensajes ? t.mensajes.length : 0"></span>
|
||||
</span>
|
||||
<span x-show="t.asignado" class="ml-2 text-slate-400">
|
||||
👤 <span x-text="t.asignado.name"></span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Cambiar estado -->
|
||||
<!-- Acciones -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0" @click.stop>
|
||||
<select @change="cambiarEstado(t, $event.target.value)"
|
||||
class="text-xs border border-slate-200 rounded-lg px-2 py-1.5 outline-none bg-white text-slate-700">
|
||||
@@ -59,12 +62,14 @@
|
||||
<option value="resuelto" :selected="t.estado==='resuelto'">Resuelto</option>
|
||||
<option value="cerrado" :selected="t.estado==='cerrado'">Cerrado</option>
|
||||
</select>
|
||||
<a :href="`/app/proyectos/${t.proyecto_id}/detalle`" title="Ver proyecto"
|
||||
class="text-slate-400 hover:text-slate-600 transition-colors" @click.stop>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Asignar -->
|
||||
<select @change="asignar(t, $event.target.value)"
|
||||
class="text-xs border border-slate-200 rounded-lg px-2 py-1.5 outline-none bg-white text-slate-700">
|
||||
<option value="">Sin asignar</option>
|
||||
<template x-for="a in admins" :key="a.id">
|
||||
<option :value="a.id" :selected="t.asignado_a===a.id" x-text="a.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,12 +113,17 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Paginación simple -->
|
||||
<div x-show="!loading && tickets.length > 0" class="mt-4 text-xs text-slate-400 text-center">
|
||||
Mostrando <span x-text="tickets.length"></span> tickets
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ticketsAdmin() {
|
||||
return {
|
||||
tickets: [],
|
||||
admins: [],
|
||||
loading: true,
|
||||
filtro: 'todos',
|
||||
estados: [
|
||||
@@ -125,12 +135,20 @@ function ticketsAdmin() {
|
||||
],
|
||||
|
||||
async init() {
|
||||
await this.cargarAdmins();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('ticket')) this.filtro = 'todos';
|
||||
await this.load();
|
||||
this.openTicketFromQuery();
|
||||
},
|
||||
|
||||
async cargarAdmins() {
|
||||
try {
|
||||
const r = await axios.get('/app/tickets/admins');
|
||||
this.admins = r.data || [];
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
@@ -158,6 +176,20 @@ function ticketsAdmin() {
|
||||
t.estado = estado;
|
||||
},
|
||||
|
||||
async asignar(t, userId) {
|
||||
if (!userId) {
|
||||
await axios.put(`/app/tickets/${t.ID}/asignar`, { asignado_id: 0 });
|
||||
t.asignado_a = null;
|
||||
t.asignado = null;
|
||||
} else {
|
||||
const id = parseInt(userId);
|
||||
await axios.put(`/app/tickets/${t.ID}/asignar`, { asignado_id: id });
|
||||
t.asignado_a = id;
|
||||
const a = this.admins.find(x => x.id === id);
|
||||
t.asignado = a ? { name: a.name } : null;
|
||||
}
|
||||
},
|
||||
|
||||
async responder(t) {
|
||||
const contenido = (t._reply || '').trim();
|
||||
if (!contenido) return;
|
||||
|
||||
@@ -322,8 +322,8 @@ func PortalCrearTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
t := &models.ProyectoTicket{
|
||||
ProyectoID: proy.ID,
|
||||
PortalUserID: u.ID,
|
||||
ProyectoID: &proy.ID,
|
||||
PortalUserID: &u.ID,
|
||||
AutorNombre: u.Nombre,
|
||||
Titulo: req.Titulo,
|
||||
Descripcion: req.Descripcion,
|
||||
@@ -361,7 +361,7 @@ func PortalResponderTicket(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
|
||||
}
|
||||
if ticket.PortalUserID != u.ID {
|
||||
if ticket.PortalUserID == nil || *ticket.PortalUserID != u.ID {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"})
|
||||
}
|
||||
msg := &models.TicketMensaje{
|
||||
@@ -375,8 +375,10 @@ func PortalResponderTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
go func() {
|
||||
proyNombre := ""
|
||||
if proy, err := models.GetProyectoByID(ticket.ProyectoID); err == nil {
|
||||
proyNombre = proy.Nombre
|
||||
if ticket.ProyectoID != nil {
|
||||
if proy, err := models.GetProyectoByID(*ticket.ProyectoID); err == nil {
|
||||
proyNombre = proy.Nombre
|
||||
}
|
||||
}
|
||||
services.DispatchTicketRespuestaCliente(ticket, req.Contenido, proyNombre)
|
||||
}()
|
||||
@@ -548,7 +550,7 @@ func PortalMarcarTicketLeido(c *fiber.Ctx) error {
|
||||
if err != nil || ticket == nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "ticket no encontrado"})
|
||||
}
|
||||
if ticket.PortalUserID != u.ID {
|
||||
if ticket.PortalUserID == nil || *ticket.PortalUserID != u.ID {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "sin permiso"})
|
||||
}
|
||||
_ = models.MarkTicketMessagesReadByPortal(uint(ticketID))
|
||||
|
||||
@@ -578,8 +578,10 @@ func AdminResponderTicket(c *fiber.Ctx) error {
|
||||
return
|
||||
}
|
||||
proyNombre := ""
|
||||
if proy, err := models.GetProyectoByID(ticket.ProyectoID); err == nil {
|
||||
proyNombre = proy.Nombre
|
||||
if ticket.ProyectoID != nil {
|
||||
if proy, err := models.GetProyectoByID(*ticket.ProyectoID); err == nil {
|
||||
proyNombre = proy.Nombre
|
||||
}
|
||||
}
|
||||
services.DispatchTicketRespuestaAdmin(ticket, req.Contenido, proyNombre)
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// ─── Webhook de correo entrante ───────────────────────────────────────────────
|
||||
// Recibe notificaciones de SendGrid, Mailgun, etc.
|
||||
// POST /webhooks/soporte/:provider
|
||||
// provider: sendgrid, mailgun, generic
|
||||
|
||||
func SoporteWebhook(c *fiber.Ctx) error {
|
||||
provider := c.Params("provider", "generic")
|
||||
|
||||
cfg, err := models.GetSoporteWebhookActivo()
|
||||
if err != nil {
|
||||
log.Printf("[SoporteWebhook] Sin config activa: %v", err)
|
||||
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "sin config"})
|
||||
}
|
||||
|
||||
var emails []struct {
|
||||
From string `json:"from" form:"from"`
|
||||
Subject string `json:"subject" form:"subject"`
|
||||
Text string `json:"text" form:"text"`
|
||||
Html string `json:"html" form:"html"`
|
||||
Sender string `json:"sender" form:"sender"`
|
||||
FromName string `json:"from_name" form:"from_name"`
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "sendgrid":
|
||||
var sg struct {
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
Html string `json:"html"`
|
||||
Sender string `json:"sender"`
|
||||
FromName string `json:"from_name"`
|
||||
}
|
||||
if err := c.BodyParser(&sg); err != nil {
|
||||
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "body inválido"})
|
||||
}
|
||||
emails = append(emails, struct {
|
||||
From string `json:"from" form:"from"`
|
||||
Subject string `json:"subject" form:"subject"`
|
||||
Text string `json:"text" form:"text"`
|
||||
Html string `json:"html" form:"html"`
|
||||
Sender string `json:"sender" form:"sender"`
|
||||
FromName string `json:"from_name" form:"from_name"`
|
||||
}{From: sg.From, Subject: sg.Subject, Text: sg.Text, Html: sg.Html, Sender: sg.Sender, FromName: sg.FromName})
|
||||
case "mailgun":
|
||||
var mg struct {
|
||||
From string `form:"from"`
|
||||
Subject string `form:"subject"`
|
||||
Text string `form:"body-plain"`
|
||||
Html string `form:"body-html"`
|
||||
Sender string `form:"sender"`
|
||||
FromName string `form:"from_name"`
|
||||
}
|
||||
if err := c.BodyParser(&mg); err != nil {
|
||||
return c.Status(200).JSON(fiber.Map{"ok": false})
|
||||
}
|
||||
emails = append(emails, struct {
|
||||
From string `json:"from" form:"from"`
|
||||
Subject string `json:"subject" form:"subject"`
|
||||
Text string `json:"text" form:"text"`
|
||||
Html string `json:"html" form:"html"`
|
||||
Sender string `json:"sender" form:"sender"`
|
||||
FromName string `json:"from_name" form:"from_name"`
|
||||
}{
|
||||
From: mg.From, Subject: mg.Subject, Text: mg.Text,
|
||||
Html: mg.Html, Sender: mg.Sender, FromName: mg.FromName,
|
||||
})
|
||||
default:
|
||||
var generic struct {
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
Html string `json:"html"`
|
||||
}
|
||||
if err := c.BodyParser(&generic); err != nil {
|
||||
return c.Status(200).JSON(fiber.Map{"ok": false})
|
||||
}
|
||||
emails = append(emails, struct {
|
||||
From string `json:"from" form:"from"`
|
||||
Subject string `json:"subject" form:"subject"`
|
||||
Text string `json:"text" form:"text"`
|
||||
Html string `json:"html" form:"html"`
|
||||
Sender string `json:"sender" form:"sender"`
|
||||
FromName string `json:"from_name" form:"from_name"`
|
||||
}{
|
||||
From: generic.From, Subject: generic.Subject, Text: generic.Text, Html: generic.Html,
|
||||
})
|
||||
}
|
||||
|
||||
for _, e := range emails {
|
||||
if e.From == "" || e.Subject == "" {
|
||||
continue
|
||||
}
|
||||
fromEmail := extractEmail(e.From)
|
||||
fromName := e.FromName
|
||||
if fromName == "" {
|
||||
fromName = extractName(e.From)
|
||||
}
|
||||
if fromName == "" {
|
||||
fromName = fromEmail
|
||||
}
|
||||
|
||||
contenido := e.Text
|
||||
if contenido == "" {
|
||||
contenido = e.Html
|
||||
}
|
||||
contenido = strings.TrimSpace(contenido)
|
||||
if len(contenido) > 5000 {
|
||||
contenido = contenido[:5000]
|
||||
}
|
||||
|
||||
ticket := &models.ProyectoTicket{
|
||||
AutorNombre: fromName,
|
||||
EmailFrom: fromEmail,
|
||||
Titulo: e.Subject,
|
||||
Descripcion: contenido,
|
||||
Estado: "abierto",
|
||||
Origen: "email",
|
||||
}
|
||||
if cfg.AsignarA != nil {
|
||||
ticket.AsignadoA = cfg.AsignarA
|
||||
}
|
||||
if err := models.CreateProyectoTicket(ticket); err != nil {
|
||||
log.Printf("[SoporteWebhook] Error creando ticket: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[SoporteWebhook] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
||||
|
||||
// Notificar admin
|
||||
services.SendSoporteNotifAdmin(ticket)
|
||||
|
||||
// Auto-responder
|
||||
if cfg.ResponderAuto {
|
||||
services.SendSoporteAutoRespuesta(ticket)
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(200).JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── Asignar ticket a usuario ─────────────────────────────────────────────────
|
||||
// PUT /app/tickets/:ticketID/asignar
|
||||
|
||||
func AsignarTicket(c *fiber.Ctx) error {
|
||||
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||
type Req struct{ AsignadoID uint `json:"asignado_id"` }
|
||||
var req Req
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
if err := app.Http.Database.DB.Model(&models.ProyectoTicket{}).
|
||||
Where("id = ?", ticketID).
|
||||
Update("asignado_a", req.AsignadoID).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── Listar admins para asignación ────────────────────────────────────────────
|
||||
// GET /app/tickets/admins
|
||||
|
||||
func GetSoporteAdmins(c *fiber.Ctx) error {
|
||||
var admins []models.Users
|
||||
app.Http.Database.DB.Where("is_admin = ?", true).
|
||||
Or("role_id IN (SELECT id FROM roles WHERE name = 'soporte' OR name = 'admin')").
|
||||
Find(&admins)
|
||||
type item struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
result := make([]item, 0)
|
||||
for _, a := range admins {
|
||||
result = append(result, item{ID: a.ID, Name: a.Name})
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
// ─── Configuración del webhook ────────────────────────────────────────────────
|
||||
|
||||
func SoporteWebhookConfigPage(c *fiber.Ctx) error {
|
||||
cfg, _ := models.GetSoporteWebhookActivo()
|
||||
return c.Render("soporte_webhook", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
"cfg": cfg,
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func GetSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
cfg, err := models.GetSoporteWebhookActivo()
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{"data": nil})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": cfg})
|
||||
}
|
||||
|
||||
func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
type body struct {
|
||||
ID uint `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Provider string `json:"provider"`
|
||||
ApiKey string `json:"api_key"`
|
||||
EmailDestino string `json:"email_destino"`
|
||||
ResponderAuto bool `json:"responder_auto"`
|
||||
MensajeAuto string `json:"mensaje_auto"`
|
||||
AsignarA *uint `json:"asignar_a"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
cfg := &models.SoporteWebhookConfig{
|
||||
Nombre: b.Nombre,
|
||||
Provider: b.Provider,
|
||||
ApiKey: b.ApiKey,
|
||||
EmailDestino: b.EmailDestino,
|
||||
ResponderAuto: b.ResponderAuto,
|
||||
MensajeAuto: b.MensajeAuto,
|
||||
AsignarA: b.AsignarA,
|
||||
Activo: true,
|
||||
}
|
||||
cfg.ID = b.ID
|
||||
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func extractEmail(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.LastIndex(s, "<"); idx >= 0 {
|
||||
s = s[idx+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, ">"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func extractName(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "<"); idx >= 0 {
|
||||
return strings.TrimSpace(s[:idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractEmailSimple(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"user@example.com", "user@example.com"},
|
||||
{"<user@example.com>", "user@example.com"},
|
||||
{"John Doe <john@example.com>", "john@example.com"},
|
||||
{"\"John Doe\" <john@example.com>", "john@example.com"},
|
||||
{" spaced@example.com ", "spaced@example.com"},
|
||||
{"", ""},
|
||||
{"<onlybrackets>", "onlybrackets"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := extractEmail(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNameSimple(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"John Doe <john@example.com>", "John Doe"},
|
||||
{"<user@example.com>", ""},
|
||||
{"user@example.com", ""},
|
||||
{" Spaces Here <spaces@example.com>", "Spaces Here"},
|
||||
{"\"Quoted Name\" <q@example.com>", "\"Quoted Name\""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := extractName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmailRealWorld(t *testing.T) {
|
||||
inputs := []struct {
|
||||
full string
|
||||
mail string
|
||||
name string
|
||||
}{
|
||||
{"María López <maria@example.com>", "maria@example.com", "María López"},
|
||||
{"soporte@u-s.app", "soporte@u-s.app", ""},
|
||||
{"Cliente Final <cliente+tag@dominio.co>", "cliente+tag@dominio.co", "Cliente Final"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, tt := range inputs {
|
||||
gotMail := extractEmail(tt.full)
|
||||
gotName := extractName(tt.full)
|
||||
if gotMail != tt.mail {
|
||||
t.Errorf("extractEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail)
|
||||
}
|
||||
if gotName != tt.name {
|
||||
t.Errorf("extractName(%q) = %q, want %q", tt.full, gotName, tt.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmailEdgeCases(t *testing.T) {
|
||||
// Formato RFC 5322 con nombre y ángulos
|
||||
if got := extractEmail("a<b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("extractEmail('a<b@c.com>') = %q, want 'b@c.com'", got)
|
||||
}
|
||||
// Múltiples brackets — usa el último par
|
||||
if got := extractEmail("<a><b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("extractEmail('<a><b@c.com>') = %q, want 'b@c.com'", got)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,10 @@ func RutasPublicas(web fiber.Router) {
|
||||
// ─── Página de estado del sistema (Atlassian Statuspage) ────────────────
|
||||
web.Get("/status", controllers.StatusPage)
|
||||
|
||||
// ─── Webhook de soporte (correo entrante) ────────────────────────────────
|
||||
// Configurar en SendGrid/Mailgun: POST {HOST}/webhooks/soporte/{provider}
|
||||
web.Post("/webhooks/soporte/:provider", controllers.SoporteWebhook)
|
||||
|
||||
// ─── Landing Generator (acceso con X-Landing-Secret) ─────────────────────
|
||||
// Rutas públicas (sin secret) para lectura de preview y polling
|
||||
web.Get("/landing/preview/:token", apiControllers.LandingGetSession)
|
||||
|
||||
@@ -454,6 +454,13 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/tickets/data", controllers.GetAllTicketsAdmin)
|
||||
protected.Put("/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
|
||||
protected.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
|
||||
protected.Put("/tickets/:ticketID/asignar", controllers.AsignarTicket)
|
||||
protected.Get("/tickets/admins", controllers.GetSoporteAdmins)
|
||||
|
||||
// Configuración webhook de soporte
|
||||
protected.Get("/soporte/webhook", middlewares.MenuMiddleware, controllers.SoporteWebhookConfigPage)
|
||||
protected.Get("/soporte/webhook/data", controllers.GetSoporteWebhookConfig)
|
||||
protected.Post("/soporte/webhook", controllers.SaveSoporteWebhookConfig)
|
||||
|
||||
// Configuración de notificaciones
|
||||
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
|
||||
|
||||
Reference in New Issue
Block a user