diff --git a/main.go b/main.go index 5a70367..240a018 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{}, // Automatización de cotizaciones, contratos, actas y cuentas de cobro con IA &models.PlantillaDocumento{}, &models.Tarifa{}, 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/query_history.go b/pkg/models/query_history.go index 57a88b8..6505782 100644 --- a/pkg/models/query_history.go +++ b/pkg/models/query_history.go @@ -12,6 +12,7 @@ type QueryHistory struct { gorm.Model ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"` ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"` + UserID uint `json:"user_id" gorm:"column:user_id;index;default:0"` SQL string `json:"sql" gorm:"column:sql;type:text"` Status string `json:"status" gorm:"column:status"` // ok | error ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"` @@ -29,8 +30,23 @@ func SaveQueryHistory(h QueryHistory) error { return app.Http.Database.DB.Create(&h).Error } -// GetQueryHistory devuelve el historial de una conexión con paginación. -func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) { +// GetQueryHistory devuelve el historial de una conexión con paginación, filtrado por usuario. +func GetQueryHistory(conxDbID, userID uint, limit, offset int) ([]QueryHistory, int64, error) { + var items []QueryHistory + var total int64 + db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ? AND user_id = ?", conxDbID, userID) + db.Count(&total) + err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error + return items, total, err +} + +// DeleteQueryHistory elimina el historial de una conexión para un usuario. +func DeleteQueryHistory(conxDbID, userID uint) error { + return app.Http.Database.DB.Where("conx_db_id = ? AND user_id = ?", conxDbID, userID).Delete(&QueryHistory{}).Error +} + +// GetQueryHistoryAdmin devuelve todo el historial de una conexión (solo admin). +func GetQueryHistoryAdmin(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) { var items []QueryHistory var total int64 db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID) @@ -39,7 +55,7 @@ func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, e return items, total, err } -// DeleteQueryHistory elimina todo el historial de una conexión. -func DeleteQueryHistory(conxDbID uint) error { +// DeleteQueryHistoryAdmin elimina todo el historial de una conexión (solo admin). +func DeleteQueryHistoryAdmin(conxDbID uint) error { return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error } diff --git a/pkg/models/roles.go b/pkg/models/roles.go index 50c93a5..8e1159d 100755 --- a/pkg/models/roles.go +++ b/pkg/models/roles.go @@ -17,6 +17,7 @@ type Roles struct { EsPortalCliente bool `json:"es_portal_cliente" gorm:"column:es_portal_cliente;default:false"` EsPortalPartner bool `json:"es_portal_partner" gorm:"column:es_portal_partner;default:false"` Submodules []Submodules `json:"submodules" gorm:"many2many:roles_submodules"` + ConxDBs []ConxDb `json:"conx_dbs" gorm:"many2many:roles_conx_db"` } // TableName overrides the table name used by Modules to `modules` @@ -41,7 +42,7 @@ func AllRoles(limit, offset int, search string) ([]Roles, int64, error) { } // Obtener los módulos con paginación - if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Find(&roles).Error; err != nil { + if err := db.Order("id DESC").Limit(limit).Offset(offset).Preload("Submodules").Preload("ConxDBs").Find(&roles).Error; err != nil { return nil, 0, err } @@ -62,6 +63,14 @@ func AllRolesSelect() ([]Roles, error) { // CreateModule creates a new rol func CreateRole(role Roles) error { + var conxDbs []ConxDb + for _, db := range role.ConxDBs { + var found ConxDb + if err := app.Http.Database.DB.First(&found, db.ID).Error; err == nil { + conxDbs = append(conxDbs, found) + } + } + role.ConxDBs = conxDbs if err := app.Http.Database.DB.Create(&role).Error; err != nil { return err } diff --git a/pkg/models/soporte_webhook.go b/pkg/models/soporte_webhook.go new file mode 100644 index 0000000..a83ea77 --- /dev/null +++ b/pkg/models/soporte_webhook.go @@ -0,0 +1,48 @@ +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 + + // SMTP salida para notificaciones y auto-respuesta + SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"` + SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"` + SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"` + SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES + SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none + SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"` + SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"` +} + +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..b2d43b4 --- /dev/null +++ b/pkg/models/soporte_webhook_test.go @@ -0,0 +1,69 @@ +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) + } +} + +func TestSoporteWebhookConfigSmtpDefaults(t *testing.T) { + cfg := SoporteWebhookConfig{Nombre: "SMTP Test"} + // Go zero values — los defaults reales los aplica GORM o el controller + if cfg.SmtpHost != "" { + t.Errorf("SmtpHost debe ser vacío por defecto, got %q", cfg.SmtpHost) + } + if cfg.SmtpPort != 0 { + t.Errorf("SmtpPort debe ser 0 en Go, got %d", cfg.SmtpPort) + } +} diff --git a/pkg/models/users.go b/pkg/models/users.go index 5586003..97523b9 100755 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -175,7 +175,7 @@ func FindUserByID(id interface{}) (*Users, error) { db := app.Http.Database.DB.Model(&Users{}) // Buscar el usuario por ID - if err := db.Where("id = ?", id).Preload("Role.Submodules.Module").First(&user).Error; err != nil { + if err := db.Where("id = ?", id).Preload("Role.Submodules.Module").Preload("Role.ConxDBs").First(&user).Error; err != nil { // Manejar el error si el registro no se encuentra if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("usuario no encontrado con ID: %d", id) 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/query_runner_service.go b/pkg/services/query_runner_service.go index 3ec5bba..c200d6b 100644 --- a/pkg/services/query_runner_service.go +++ b/pkg/services/query_runner_service.go @@ -82,18 +82,22 @@ func openDynamicDB(c models.ConxDb) (*sql.DB, error) { // ExecuteSQL ejecuta SQL arbitrario contra la conexión y devuelve QueryResult. // También guarda en query_history. -func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult { +func ExecuteSQL(conx models.ConxDb, database, sqlText string, userID ...uint) QueryResult { + uid := uint(0) + if len(userID) > 0 { + uid = userID[0] + } if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) { - return mongoExecuteSQL(conx, database, sqlText) + return mongoExecuteSQL(conx, database, sqlText, uid) } if isRedisDriver(strings.ToLower(conx.TipoDb.Nombre)) { - return redisExecuteCommand(conx, database, sqlText) + return redisExecuteCommand(conx, database, sqlText, uid) } start := time.Now() db, err := openDynamicDB(conx) if err != nil { - saveHistory(conx.ID, sqlText, "error", err.Error(), 0, time.Since(start).Milliseconds()) + saveHistory(conx.ID, uid, sqlText, "error", err.Error(), 0, time.Since(start).Milliseconds()) return QueryResult{Error: err.Error()} } defer db.Close() @@ -110,7 +114,7 @@ func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult { } } else { if _, err2 := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err2 != nil { - saveHistory(conx.ID, sqlText, "error", err2.Error(), 0, time.Since(start).Milliseconds()) + saveHistory(conx.ID, uid, sqlText, "error", err2.Error(), 0, time.Since(start).Milliseconds()) return QueryResult{Error: err2.Error()} } } @@ -126,7 +130,7 @@ func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult { rows, err := db.Query(trimmed) if err != nil { elapsed := time.Since(start).Milliseconds() - saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed) + saveHistory(conx.ID, uid, sqlText, "error", err.Error(), 0, elapsed) return QueryResult{Error: err.Error(), IsSelect: true} } defer rows.Close() @@ -170,7 +174,7 @@ func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult { if len(preview) > 80 { preview = preview[:80] + "..." } - saveHistory(conx.ID, sqlText, "error", execErr.Error(), 0, elapsed) + saveHistory(conx.ID, uid, sqlText, "error", execErr.Error(), 0, elapsed) return QueryResult{Error: fmt.Sprintf("[%s]: %s", preview, execErr.Error())} } if affected, err2 := res.RowsAffected(); err2 == nil { @@ -180,7 +184,7 @@ func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult { elapsed := time.Since(start).Milliseconds() result.AffectedRows = totalAffected result.DurationMs = elapsed - saveHistory(conx.ID, sqlText, "ok", "", totalAffected, elapsed) + saveHistory(conx.ID, uid, sqlText, "ok", "", totalAffected, elapsed) return result } } @@ -188,18 +192,18 @@ func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult { res, err := db.Exec(trimmed) elapsed := time.Since(start).Milliseconds() if err != nil { - saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed) + saveHistory(conx.ID, uid, sqlText, "error", err.Error(), 0, elapsed) return QueryResult{Error: err.Error()} } affected, _ := res.RowsAffected() result.AffectedRows = affected result.DurationMs = elapsed - saveHistory(conx.ID, sqlText, "ok", "", affected, elapsed) + saveHistory(conx.ID, uid, sqlText, "ok", "", affected, elapsed) return result } result.DurationMs = time.Since(start).Milliseconds() - saveHistory(conx.ID, sqlText, "ok", "", int64(result.RowCount), result.DurationMs) + saveHistory(conx.ID, uid, sqlText, "ok", "", int64(result.RowCount), result.DurationMs) return result } @@ -559,9 +563,10 @@ func quoteIdentifier(name, driver string) string { return "`" + strings.ReplaceAll(name, "`", "``") + "`" } -func saveHistory(conxID uint, sqlText, status, errMsg string, rows, durationMs int64) { +func saveHistory(conxID, userID uint, sqlText, status, errMsg string, rows, durationMs int64) { models.SaveQueryHistory(models.QueryHistory{ ConxDbID: conxID, + UserID: userID, SQL: sqlText, Status: status, ErrorMsg: errMsg, @@ -670,7 +675,11 @@ func redisListKeys(c models.ConxDb, database string) ([]string, error) { // redisExecuteCommand parsea y ejecuta un comando Redis. // Los comandos se escriben como en redis-cli: GET key / SET key value / etc. // Soporta múltiples líneas: cada línea no vacía es un comando independiente. -func redisExecuteCommand(conx models.ConxDb, database, cmdText string) QueryResult { +func redisExecuteCommand(conx models.ConxDb, database, cmdText string, userID ...uint) QueryResult { + uid := uint(0) + if len(userID) > 0 { + uid = userID[0] + } start := time.Now() dbIndex := 0 @@ -682,7 +691,7 @@ func redisExecuteCommand(conx models.ConxDb, database, cmdText string) QueryResu client, err := redisConnect(conx, dbIndex) if err != nil { - saveHistory(conx.ID, cmdText, "error", err.Error(), 0, time.Since(start).Milliseconds()) + saveHistory(conx.ID, uid, cmdText, "error", err.Error(), 0, time.Since(start).Milliseconds()) return QueryResult{Error: err.Error()} } defer client.Close() @@ -735,7 +744,7 @@ func redisExecuteCommand(conx models.ConxDb, database, cmdText string) QueryResu IsSelect: true, DurationMs: elapsed, } - saveHistory(conx.ID, cmdText, "ok", "", int64(len(rows)), elapsed) + saveHistory(conx.ID, uid, cmdText, "ok", "", int64(len(rows)), elapsed) return result } @@ -753,13 +762,13 @@ func redisExecuteCommand(conx models.ConxDb, database, cmdText string) QueryResu elapsed := time.Since(start).Milliseconds() if execErr != nil && execErr != goredis.Nil { - saveHistory(conx.ID, cmdText, "error", execErr.Error(), 0, elapsed) + saveHistory(conx.ID, uid, cmdText, "error", execErr.Error(), 0, elapsed) return QueryResult{Error: execErr.Error(), DurationMs: elapsed} } result := redisResultToQueryResult(val, lines[0]) result.DurationMs = elapsed - saveHistory(conx.ID, cmdText, "ok", "", int64(result.RowCount), elapsed) + saveHistory(conx.ID, uid, cmdText, "ok", "", int64(result.RowCount), elapsed) return result } @@ -1052,11 +1061,15 @@ func mongoSingleToDoubleQuotes(s string) string { return b.String() } -func mongoExecuteSQL(conx models.ConxDb, database, queryText string) QueryResult { +func mongoExecuteSQL(conx models.ConxDb, database, queryText string, userID ...uint) QueryResult { + uid := uint(0) + if len(userID) > 0 { + uid = userID[0] + } start := time.Now() client, err := mongoConnect(conx) if err != nil { - saveHistory(conx.ID, queryText, "error", err.Error(), 0, time.Since(start).Milliseconds()) + saveHistory(conx.ID, uid, queryText, "error", err.Error(), 0, time.Since(start).Milliseconds()) return QueryResult{Error: err.Error()} } defer client.Disconnect(context.Background()) //nolint @@ -1066,11 +1079,11 @@ func mongoExecuteSQL(conx models.ConxDb, database, queryText string) QueryResult result, err := mongoRunQuery(ctx, db, strings.TrimSpace(queryText)) elapsed := time.Since(start).Milliseconds() if err != nil { - saveHistory(conx.ID, queryText, "error", err.Error(), 0, elapsed) + saveHistory(conx.ID, uid, queryText, "error", err.Error(), 0, elapsed) return QueryResult{Error: err.Error()} } result.DurationMs = elapsed - saveHistory(conx.ID, queryText, "ok", "", int64(result.RowCount)+result.AffectedRows, elapsed) + saveHistory(conx.ID, uid, queryText, "ok", "", int64(result.RowCount)+result.AffectedRows, elapsed) return *result } diff --git a/pkg/services/soporte_service.go b/pkg/services/soporte_service.go new file mode 100644 index 0000000..a43c690 --- /dev/null +++ b/pkg/services/soporte_service.go @@ -0,0 +1,167 @@ +package services + +import ( + "crypto/tls" + "fmt" + "log" + "net/smtp" + "strings" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// soporteSendMail envía un correo usando la configuración SMTP del webhook de soporte. +// Si no hay SMTP configurado, usa app.Http.Mail como fallback. +func soporteSendMail(to, subject, htmlBody string) error { + cfg, err := models.GetSoporteWebhookActivo() + if err != nil || cfg == nil || cfg.SmtpHost == "" { + return app.Http.Mail.Send(to, subject, htmlBody) + } + from := cfg.SmtpFromAddr + if from == "" { + from = cfg.EmailDestino + } + if from == "" { + return app.Http.Mail.Send(to, subject, htmlBody) + } + fromName := cfg.SmtpFromName + if fromName == "" { + fromName = "Soporte" + } + port := cfg.SmtpPort + if port == 0 { + port = 587 + } + auth := smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost) + msg := []byte(fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", fromName, from, to, subject, htmlBody)) + addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port) + enc := strings.ToLower(cfg.SmtpEncryption) + if enc == "tls" { + tlsCfg := &tls.Config{ServerName: cfg.SmtpHost} + conn, err := tls.Dial("tcp", addr, tlsCfg) + if err != nil { + return fmt.Errorf("soporte SMTP TLS: %w", err) + } + client, err := smtp.NewClient(conn, cfg.SmtpHost) + if err != nil { + conn.Close() + return fmt.Errorf("soporte SMTP client: %w", err) + } + defer client.Close() + if err = client.Auth(auth); err != nil { + return fmt.Errorf("soporte SMTP auth: %w", err) + } + if err = client.Mail(from); err != nil { + return err + } + if err = client.Rcpt(to); err != nil { + return err + } + w, err := client.Data() + if err != nil { + return err + } + _, err = w.Write(msg) + if err != nil { + return err + } + return w.Close() + } + if enc == "starttls" { + tlsCfg := &tls.Config{ServerName: cfg.SmtpHost} + conn, err := smtp.Dial(addr) + if err != nil { + goto fallback + } + if err = conn.StartTLS(tlsCfg); err != nil { + conn.Close() + goto fallback + } + if err = conn.Auth(auth); err != nil { + conn.Close() + return fmt.Errorf("soporte SMTP STARTTLS auth: %w", err) + } + return smtp.SendMail(addr, auth, from, []string{to}, msg) + } + return smtp.SendMail(addr, auth, from, []string{to}, msg) + +fallback: + return app.Http.Mail.Send(to, subject, htmlBody) +} + +// 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 := soporteSendMail(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 := soporteSendMail(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/query_runner.html b/resources/views/query_runner.html index 4d8d176..74ee195 100644 --- a/resources/views/query_runner.html +++ b/resources/views/query_runner.html @@ -136,6 +136,19 @@ Limpiar + + + + + +