fix: seguridad del webhook de soporte, pagos en contabilidad y Telegram para tareas

- soporte: el webhook de correo entrante era público sin ninguna validación;
  ahora exige una API key (query ?key= o header) comparada en tiempo constante.
  Además evita tickets duplicados por reintentos del proveedor (dedup por
  Message-Id) y enhebra respuestas del mismo remitente en vez de abrir un
  ticket nuevo por cada correo.
- contabilidad: marcar una cuenta por cobrar/pagar como pagada ahora crea y
  vincula la Transaccion correspondiente (antes el dashboard de ingresos/
  egresos nunca reflejaba esos pagos). Se corrige además que actualizar una
  cuenta por cobrar borraba su transaccion_id en cada PUT.
- tareas: se activa por defecto el canal Telegram para tarea_asignada (estaba
  apagado desde el seed original) y se agrega un flujo real de vinculación de
  Telegram para el staff interno (código temporal + verificación), igual al
  que ya existía para los usuarios del portal — sin esto el chat_id de cada
  usuario había que pegarlo a mano y la notificación nunca llegaba.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-08-03 01:25:17 +00:00
co-authored by Claude Sonnet 5
parent 1127d944c7
commit d2bf699b60
14 changed files with 612 additions and 100 deletions
+61 -3
View File
@@ -490,17 +490,50 @@ func CreateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Create(cc).Error
}
// UpdateCuentaCobro actualiza los campos editables de una cuenta por cobrar.
// No toca transaccion_id a propósito: ese vínculo solo lo debe crear
// MarcarCuentaCobroPagada, para no perderlo en una edición cualquiera.
func UpdateCuentaCobro(cc *CuentaCobro) error {
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{
"cliente_id": cc.ClienteID,
"estado": cc.Estado,
"fecha_vencimiento": cc.FechaVencimiento,
"fecha_pago": cc.FechaPago,
"transaccion_id": cc.TransaccionID,
"notas": cc.Notas,
}).Error
}
// MarcarCuentaCobroPagada registra el pago de una cuenta por cobrar: crea la
// Transaccion de tipo "ingreso" correspondiente y la vincula. Es idempotente —
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
func MarcarCuentaCobroPagada(id uint, fechaPago time.Time) error {
var cc CuentaCobro
if err := app.Http.Database.DB.Preload("Cliente").First(&cc, id).Error; err != nil {
return err
}
if cc.Estado == "pagado" {
return nil
}
desc := fmt.Sprintf("Pago cuenta por cobrar #%d: %s", cc.ID, cc.Descripcion)
if cc.Cliente.Nombre != "" {
desc = fmt.Sprintf("Pago cuenta por cobrar #%d (%s): %s", cc.ID, cc.Cliente.Nombre, cc.Descripcion)
}
t := &Transaccion{
Fecha: fechaPago,
Tipo: "ingreso",
Descripcion: desc,
Valor: cc.Valor,
}
if err := app.Http.Database.DB.Create(t).Error; err != nil {
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
}
return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", id).Updates(map[string]interface{}{
"estado": "pagado",
"fecha_pago": fechaPago,
"transaccion_id": t.ID,
}).Error
}
func DeleteCuentaCobro(id uint) error {
return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error
}
@@ -548,10 +581,35 @@ func UpdateCuentaPagar(cp *CuentaPagar) error {
}).Error
}
// MarcarCuentaPagarPagada registra el pago de una cuenta por pagar: crea la
// Transaccion de tipo "egreso" correspondiente y la vincula. Es idempotente —
// si la cuenta ya estaba pagada, no crea una transacción duplicada.
func MarcarCuentaPagarPagada(id uint, fechaPago time.Time) error {
var cp CuentaPagar
if err := app.Http.Database.DB.Preload("Entidad").First(&cp, id).Error; err != nil {
return err
}
if cp.Estado == "pagado" {
return nil
}
desc := fmt.Sprintf("Pago cuenta por pagar #%d: %s", cp.ID, cp.Descripcion)
if cp.Entidad.Nombre != "" {
desc = fmt.Sprintf("Pago cuenta por pagar #%d (%s): %s", cp.ID, cp.Entidad.Nombre, cp.Descripcion)
}
t := &Transaccion{
Fecha: fechaPago,
Tipo: "egreso",
Descripcion: desc,
Valor: cp.Valor,
EntidadID: &cp.EntidadID,
}
if err := app.Http.Database.DB.Create(t).Error; err != nil {
return fmt.Errorf("no se pudo crear la transacción de pago: %w", err)
}
return app.Http.Database.DB.Model(&CuentaPagar{}).Where("id = ?", id).Updates(map[string]interface{}{
"estado": "pagado",
"fecha_pago": fechaPago,
"estado": "pagado",
"fecha_pago": fechaPago,
"transaccion_id": t.ID,
}).Error
}
+40 -7
View File
@@ -15,11 +15,12 @@ type ProyectoTicket struct {
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
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"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
}
@@ -29,11 +30,12 @@ func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
type TicketMensaje struct {
gorm.Model
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
LeidoPortal bool `json:"leido_portal" gorm:"column:leido_portal;default:false"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo de respuesta (dedup)
}
func (TicketMensaje) TableName() string { return "ticket_mensajes" }
@@ -66,6 +68,34 @@ func CreateTicketMensaje(m *TicketMensaje) error {
return app.Http.Database.DB.Create(m).Error
}
// EmailMessageIDYaProcesado indica si un Message-Id de correo ya generó un ticket
// o un mensaje de ticket, para no duplicar cuando el proveedor reintenta la entrega.
func EmailMessageIDYaProcesado(messageID string) bool {
if messageID == "" {
return false
}
var count int64
app.Http.Database.DB.Model(&ProyectoTicket{}).Where("message_id = ?", messageID).Count(&count)
if count > 0 {
return true
}
app.Http.Database.DB.Model(&TicketMensaje{}).Where("message_id = ?", messageID).Count(&count)
return count > 0
}
// GetUltimoTicketAbiertoPorEmail busca el ticket de origen email más reciente y no
// cerrado de un remitente, para enhebrar una respuesta en vez de abrir uno nuevo.
func GetUltimoTicketAbiertoPorEmail(email string) (*ProyectoTicket, error) {
var t ProyectoTicket
err := app.Http.Database.DB.
Where("email_from = ? AND origen = ? AND estado <> ?", email, "email", "cerrado").
Order("created_at DESC").First(&t).Error
if err != nil {
return nil, err
}
return &t, nil
}
func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
var items []ProyectoTicket
err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID).
@@ -91,7 +121,10 @@ func AssignedToUser(userID uint) error {
func CountTicketsByEstado() map[string]int64 {
result := map[string]int64{}
type row struct{ Estado string; Count int64 }
type row struct {
Estado string
Count int64
}
var rows []row
app.Http.Database.DB.Model(&ProyectoTicket{}).
Select("estado, count(*) as count").
+58
View File
@@ -0,0 +1,58 @@
package models
import (
"crypto/rand"
"fmt"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// TelegramStaffToken almacena un código de verificación temporal para que un
// usuario interno (staff) vincule su Telegram y reciba notificaciones personales
// (ej: tareas asignadas), igual que TelegramPortalToken pero para Users.
type TelegramStaffToken struct {
gorm.Model
UserID uint `json:"user_id" gorm:"column:user_id;uniqueIndex"`
Token string `json:"token" gorm:"column:token;uniqueIndex;size:8"`
}
func (TelegramStaffToken) TableName() string { return "telegram_staff_tokens" }
// GenerateTelegramStaffToken genera (o renueva) el código de vinculación para el usuario.
func GenerateTelegramStaffToken(userID uint) (*TelegramStaffToken, error) {
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("no se pudo generar token: %w", err)
}
token := fmt.Sprintf("%06X", b)
t := &TelegramStaffToken{UserID: userID, Token: token}
if err := app.Http.Database.DB.Create(t).Error; err != nil {
return nil, err
}
return t, nil
}
// GetTelegramStaffTokenByUser devuelve el token vigente de un usuario interno.
func GetTelegramStaffTokenByUser(userID uint) (*TelegramStaffToken, error) {
var t TelegramStaffToken
err := app.Http.Database.DB.Where("user_id = ?", userID).First(&t).Error
if err != nil {
return nil, err
}
return &t, nil
}
// DeleteTelegramStaffToken elimina el token de un usuario (tras vincular correctamente).
func DeleteTelegramStaffToken(userID uint) {
app.Http.Database.DB.Unscoped().Where("user_id = ?", userID).Delete(&TelegramStaffToken{})
}
// UpdateUserTelegramChatID vincula el chat_id de Telegram al usuario interno.
func UpdateUserTelegramChatID(userID uint, chatID string) error {
return app.Http.Database.DB.Model(&Users{}).Where("id = ?", userID).
Update("telegram_chat_id", chatID).Error
}
+1 -1
View File
@@ -95,7 +95,7 @@ func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) {
if ticket == nil || ticket.EmailFrom == "" {
return
}
subject := fmt.Sprintf("Recibimos tu solicitud: %s", ticket.Titulo)
subject := fmt.Sprintf("[Ticket #%d] Recibimos tu solicitud: %s", ticket.ID, 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 != "" {