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:
co-authored by
Claude Sonnet 5
parent
1127d944c7
commit
d2bf699b60
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user