From d2bf699b602bf4cb29610afe4ff501d3ded78522 Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 3 Aug 2026 01:25:17 +0000 Subject: [PATCH] fix: seguridad del webhook de soporte, pagos en contabilidad y Telegram para tareas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- main.go | 2 + migrations/migrate.go | 15 +- pkg/models/contabilidad.go | 64 ++++- pkg/models/proyecto_ticket.go | 47 +++- pkg/models/telegram_staff_token.go | 58 +++++ pkg/services/soporte_service.go | 2 +- .../views/contabilidad/cuentas_cobro.html | 2 +- resources/views/profile.html | 109 ++++++++ resources/views/soporte_webhook.html | 33 ++- rest/controllers/contabilidad_controller.go | 25 ++ rest/controllers/soporte_controller.go | 245 ++++++++++++------ rest/controllers/telegram_staff_controller.go | 105 ++++++++ rest/routes/hermes.go | 1 + rest/routes/user.go | 4 + 14 files changed, 612 insertions(+), 100 deletions(-) create mode 100644 pkg/models/telegram_staff_token.go create mode 100644 rest/controllers/telegram_staff_controller.go diff --git a/main.go b/main.go index a2d35b1..7ebf5b4 100755 --- a/main.go +++ b/main.go @@ -125,6 +125,8 @@ func main() { &models.Tarifa{}, &models.DocumentoGenerado{}, &models.Arquitectura{}, + // Vinculación de Telegram para staff interno + &models.TelegramStaffToken{}, ); err != nil { // AutoMigrate se detiene en el primer error: si esto falla, todos los // modelos que venían después en la lista se quedan sin crear en silencio. diff --git a/migrations/migrate.go b/migrations/migrate.go index d9f2eea..e2b4542 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -109,6 +109,8 @@ func Migrate() { &models.Tarifa{}, &models.DocumentoGenerado{}, &models.Arquitectura{}, + // Vinculación de Telegram para staff interno + &models.TelegramStaffToken{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } @@ -963,7 +965,7 @@ func SeedNotifDefaults() { {Evento: "servidor_vence_pronto", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando un VPS está próximo a vencer"}, {Evento: "servidor_recurso_alto", Destinatario: "admin", CanalEmail: false, CanalTelegram: true, CanalSistema: true, Descripcion: "Admin recibe cuando CPU/RAM/Disco supera el umbral"}, // Tareas - {Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"}, + {Evento: "tarea_asignada", Destinatario: "admin", CanalEmail: true, CanalTelegram: true, CanalSistema: true, Descripcion: "Notifica cuando se asigna una tarea a un usuario"}, {Evento: "tarea_estado", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando cambia el estado de una tarea"}, {Evento: "tarea_comentario", Destinatario: "admin", CanalEmail: false, CanalTelegram: false, CanalSistema: true, Descripcion: "Notifica cuando hay un nuevo comentario en una tarea"}, } @@ -976,6 +978,17 @@ func SeedNotifDefaults() { } } log.Printf("[SEED] SeedNotifDefaults: %d nuevas configuraciones creadas.", created) + + // Corrección puntual (una sola vez): tarea_asignada/admin se sembró originalmente + // con canal_telegram=false, dejando la notificación muerta desde su creación. + // Si nadie tocó el registro desde entonces, se activa por defecto. + var taRow models.NotifEventoConfig + if err := db.Where("evento = ? AND destinatario = ?", "tarea_asignada", "admin").First(&taRow).Error; err == nil { + if !taRow.CanalTelegram && taRow.UpdatedAt.Equal(taRow.CreatedAt) { + db.Model(&taRow).Update("canal_telegram", true) + log.Println("[SEED] tarea_asignada/admin: canal_telegram activado (corrección de default)") + } + } } // SeedShield es un stub idempotente para la configuración de Shield. diff --git a/pkg/models/contabilidad.go b/pkg/models/contabilidad.go index 865f8fa..1172675 100644 --- a/pkg/models/contabilidad.go +++ b/pkg/models/contabilidad.go @@ -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 } diff --git a/pkg/models/proyecto_ticket.go b/pkg/models/proyecto_ticket.go index b8f5062..435bbee 100644 --- a/pkg/models/proyecto_ticket.go +++ b/pkg/models/proyecto_ticket.go @@ -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"). diff --git a/pkg/models/telegram_staff_token.go b/pkg/models/telegram_staff_token.go new file mode 100644 index 0000000..2b7925c --- /dev/null +++ b/pkg/models/telegram_staff_token.go @@ -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 +} diff --git a/pkg/services/soporte_service.go b/pkg/services/soporte_service.go index a43c690..7bda731 100644 --- a/pkg/services/soporte_service.go +++ b/pkg/services/soporte_service.go @@ -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 != "" { diff --git a/resources/views/contabilidad/cuentas_cobro.html b/resources/views/contabilidad/cuentas_cobro.html index 566d0ce..f3cbeb4 100644 --- a/resources/views/contabilidad/cuentas_cobro.html +++ b/resources/views/contabilidad/cuentas_cobro.html @@ -174,7 +174,7 @@ function cobroApp() { async doPagar(){ this.saving=true; try { - await axios.put(`/app/contabilidad/cuentas-cobro/${this.pagarId}`, {estado:'pagado',fecha_pago:this.pagoFecha}); + await axios.post(`/app/contabilidad/cuentas-cobro/${this.pagarId}/pagar`, {fecha_pago:this.pagoFecha}); this.showPagarModal=false; await this.load(); } catch(e){ this.error=e.response?.data?.error||'Error'; } finally{ this.saving=false; } diff --git a/resources/views/profile.html b/resources/views/profile.html index 13860e2..ac9e3c1 100755 --- a/resources/views/profile.html +++ b/resources/views/profile.html @@ -76,6 +76,69 @@ + + +
+

Notificaciones por Telegram

+

Vincula tu Telegram para recibir avisos de tareas asignadas y otras notificaciones personales.

+ +
+ +
+

Telegram vinculado

+

Chat ID:

+
+
+ +
+
+ 1 +
+

Genera tu código

+ +
+
+ +
+

Tu código de vinculación:

+

+
+ +
+ 2 +
+

Envía el código al bot

+
+ +
+ +
+
+ +
+ 3 +
+

Verifica la vinculación

+ +

+
+
+
+
- -

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

+
+ + +
+

Obligatoria: las peticiones sin esta clave (como ?key=...) son rechazadas. Configúrala también en el proveedor si soporta enviarla como parámetro/header.

@@ -113,10 +119,10 @@
-
+

URL del webhook

-

-

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

+

+

Configura esta URL exacta (con la clave incluida) en el proveedor de correo para enviar los emails entrantes.

@@ -142,7 +148,6 @@ function soporteWebhook() { smtp_from_name: '', }, admins: [], - webhookUrl: '', async init() { try { @@ -156,7 +161,19 @@ function soporteWebhook() { const r = await axios.get('/app/tickets/admins'); this.admins = r.data || []; } catch {} - this.webhookUrl = window.location.origin + '/webhooks/soporte/' + this.cfg.provider; + if (!this.cfg.api_key) { + this.cfg.api_key = this.generarClave(); + } + }, + + generarClave() { + if (window.crypto && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, ''); + return Array.from(crypto.getRandomValues(new Uint8Array(24))).map(b => b.toString(16).padStart(2, '0')).join(''); + }, + + webhookUrl() { + const base = window.location.origin + '/webhooks/soporte/' + this.cfg.provider; + return this.cfg.api_key ? `${base}?key=${this.cfg.api_key}` : base; }, async guardar() { diff --git a/rest/controllers/contabilidad_controller.go b/rest/controllers/contabilidad_controller.go index 5fbb240..ef33518 100644 --- a/rest/controllers/contabilidad_controller.go +++ b/rest/controllers/contabilidad_controller.go @@ -494,6 +494,31 @@ func DeleteCuentaCobro(c *fiber.Ctx) error { return c.JSON(fiber.Map{"ok": true}) } +// MarcarCuentaCobroPagada registra el pago y crea la transacción de ingreso vinculada. +// POST /contabilidad/cuentas-cobro/:id/pagar +func MarcarCuentaCobroPagada(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var req struct { + FechaPago string `json:"fecha_pago"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + fecha := time.Now() + if req.FechaPago != "" { + if parsed, err := time.Parse("2006-01-02", req.FechaPago); err == nil { + fecha = parsed + } + } + if err := models.MarcarCuentaCobroPagada(uint(id), fecha); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + // ============================================================================= // ─── CRUD: Cuentas por Pagar ──────────────────────────────────────────────── // ============================================================================= diff --git a/rest/controllers/soporte_controller.go b/rest/controllers/soporte_controller.go index a436687..a65d565 100644 --- a/rest/controllers/soporte_controller.go +++ b/rest/controllers/soporte_controller.go @@ -1,7 +1,9 @@ package controllers import ( + "crypto/subtle" "log" + "regexp" "strconv" "strings" @@ -13,9 +15,43 @@ import ( // ─── Webhook de correo entrante ─────────────────────────────────────────────── // Recibe notificaciones de SendGrid, Mailgun, etc. -// POST /webhooks/soporte/:provider +// POST /webhooks/soporte/:provider?key= // provider: sendgrid, mailgun, generic +type soporteEmailIn 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"` + MessageID string `json:"message_id" form:"message_id"` +} + +var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`) +var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`) + +// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key / +// X-Api-Key) contra el ApiKey guardado en la config activa, en tiempo constante. +// Si la config no tiene ApiKey configurado, se rechaza toda petición: hay que +// definirlo en /app/soporte-webhook y usar esa misma URL con ?key=... en el proveedor. +func validarWebhookKey(c *fiber.Ctx, cfg *models.SoporteWebhookConfig) bool { + if cfg.ApiKey == "" { + return false + } + key := c.Query("key") + if key == "" { + key = c.Get("X-Webhook-Key") + } + if key == "" { + key = c.Get("X-Api-Key") + } + if key == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.ApiKey)) == 1 +} + func SoporteWebhook(c *fiber.Ctx) error { provider := c.Params("provider", "generic") @@ -25,78 +61,62 @@ func SoporteWebhook(c *fiber.Ctx) error { 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"` + if !validarWebhookKey(c, cfg) { + log.Printf("[SoporteWebhook] Petición rechazada (key inválida o ausente) desde IP %s", c.IP()) + return c.Status(401).JSON(fiber.Map{"ok": false, "error": "no autorizado"}) } + var emails []soporteEmailIn + 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"` + 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"` + Headers string `json:"headers" form:"headers"` } 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}) + emails = append(emails, soporteEmailIn{ + From: sg.From, Subject: sg.Subject, Text: sg.Text, Html: sg.Html, + Sender: sg.Sender, FromName: sg.FromName, MessageID: extractMessageIDFromHeaders(sg.Headers), + }) 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"` + 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"` + MessageID string `form:"Message-Id"` } 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"` - }{ + emails = append(emails, soporteEmailIn{ From: mg.From, Subject: mg.Subject, Text: mg.Text, - Html: mg.Html, Sender: mg.Sender, FromName: mg.FromName, + Html: mg.Html, Sender: mg.Sender, FromName: mg.FromName, MessageID: mg.MessageID, }) default: var generic struct { - From string `json:"from"` - Subject string `json:"subject"` - Text string `json:"text"` - Html string `json:"html"` + From string `json:"from"` + Subject string `json:"subject"` + Text string `json:"text"` + Html string `json:"html"` + MessageID string `json:"message_id"` } 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"` - }{ + emails = append(emails, soporteEmailIn{ From: generic.From, Subject: generic.Subject, Text: generic.Text, Html: generic.Html, + MessageID: generic.MessageID, }) } @@ -104,6 +124,13 @@ func SoporteWebhook(c *fiber.Ctx) error { if e.From == "" || e.Subject == "" { continue } + + // Deduplicación: el proveedor puede reintentar la entrega del mismo correo. + if models.EmailMessageIDYaProcesado(e.MessageID) { + log.Printf("[SoporteWebhook] Correo duplicado ignorado (message_id=%s)", e.MessageID) + continue + } + fromEmail := extractEmail(e.From) fromName := e.FromName if fromName == "" { @@ -122,6 +149,29 @@ func SoporteWebhook(c *fiber.Ctx) error { contenido = contenido[:5000] } + // Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el + // auto-ack) y ese ticket es del mismo remitente, es una respuesta — se + // agrega como mensaje en vez de abrir un ticket nuevo. Si no hay token + // pero el remitente tiene un ticket abierto reciente, también se enhebra. + if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil { + msg := &models.TicketMensaje{ + TicketID: hilo.ID, + Contenido: contenido, + EsAdmin: false, + AutorNombre: fromName, + MessageID: e.MessageID, + } + if err := models.CreateTicketMensaje(msg); err != nil { + log.Printf("[SoporteWebhook] Error agregando mensaje al ticket #%d: %v", hilo.ID, err) + continue + } + if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" { + _ = models.UpdateTicketEstado(hilo.ID, "abierto") + } + log.Printf("[SoporteWebhook] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail) + continue + } + ticket := &models.ProyectoTicket{ AutorNombre: fromName, EmailFrom: fromEmail, @@ -129,6 +179,7 @@ func SoporteWebhook(c *fiber.Ctx) error { Descripcion: contenido, Estado: "abierto", Origen: "email", + MessageID: e.MessageID, } if cfg.AsignarA != nil { ticket.AsignadoA = cfg.AsignarA @@ -142,7 +193,7 @@ func SoporteWebhook(c *fiber.Ctx) error { // Notificar admin services.SendSoporteNotifAdmin(ticket) - // Auto-responder + // Auto-responder (el asunto incluye [Ticket #N] para poder enhebrar la respuesta) if cfg.ResponderAuto { services.SendSoporteAutoRespuesta(ticket) } @@ -151,12 +202,48 @@ func SoporteWebhook(c *fiber.Ctx) error { return c.Status(200).JSON(fiber.Map{"ok": true}) } +// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta: +// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo +// remitente, para que nadie pueda inyectar mensajes en el ticket de otro +// adivinando el número), y si no hay token, por el último ticket abierto de ese +// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:). +func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket { + if m := ticketRefRe.FindStringSubmatch(subject); m != nil { + id, _ := strconv.ParseUint(m[1], 10, 32) + if id > 0 { + t, err := models.GetTicketByID(uint(id)) + if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) { + return t + } + } + } + lower := strings.ToLower(strings.TrimSpace(subject)) + if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") { + if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil { + return t + } + } + return nil +} + +func extractMessageIDFromHeaders(headers string) string { + if headers == "" { + return "" + } + if m := messageIDHeaderRe.FindStringSubmatch(headers); m != nil { + return m[1] + } + return "" +} + // ─── 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"` } + 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"}) @@ -209,21 +296,21 @@ func GetSoporteWebhookConfig(c *fiber.Ctx) error { 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"` - SmtpHost string `json:"smtp_host"` - SmtpPort int `json:"smtp_port"` - SmtpUsername string `json:"smtp_username"` - SmtpPassword string `json:"smtp_password"` + 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"` + SmtpHost string `json:"smtp_host"` + SmtpPort int `json:"smtp_port"` + SmtpUsername string `json:"smtp_username"` + SmtpPassword string `json:"smtp_password"` SmtpEncryption string `json:"smtp_encryption"` - SmtpFromAddr string `json:"smtp_from_addr"` - SmtpFromName string `json:"smtp_from_name"` + SmtpFromAddr string `json:"smtp_from_addr"` + SmtpFromName string `json:"smtp_from_name"` } var b body if err := c.BodyParser(&b); err != nil { @@ -238,21 +325,21 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error { enc = "starttls" } cfg := &models.SoporteWebhookConfig{ - Nombre: b.Nombre, - Provider: b.Provider, - ApiKey: b.ApiKey, - EmailDestino: b.EmailDestino, - ResponderAuto: b.ResponderAuto, - MensajeAuto: b.MensajeAuto, - AsignarA: b.AsignarA, - SmtpHost: b.SmtpHost, - SmtpPort: port, - SmtpUsername: b.SmtpUsername, - SmtpPassword: b.SmtpPassword, + Nombre: b.Nombre, + Provider: b.Provider, + ApiKey: b.ApiKey, + EmailDestino: b.EmailDestino, + ResponderAuto: b.ResponderAuto, + MensajeAuto: b.MensajeAuto, + AsignarA: b.AsignarA, + SmtpHost: b.SmtpHost, + SmtpPort: port, + SmtpUsername: b.SmtpUsername, + SmtpPassword: b.SmtpPassword, SmtpEncryption: enc, - SmtpFromAddr: b.SmtpFromAddr, - SmtpFromName: b.SmtpFromName, - Activo: true, + SmtpFromAddr: b.SmtpFromAddr, + SmtpFromName: b.SmtpFromName, + Activo: true, } cfg.ID = b.ID if err := models.SaveSoporteWebhookConfig(cfg); err != nil { diff --git a/rest/controllers/telegram_staff_controller.go b/rest/controllers/telegram_staff_controller.go new file mode 100644 index 0000000..37840ed --- /dev/null +++ b/rest/controllers/telegram_staff_controller.go @@ -0,0 +1,105 @@ +package controllers + +import ( + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +// staffUserID extrae el ID del usuario interno autenticado desde c.Locals("user"). +func staffUserID(c *fiber.Ctx) (uint, bool) { + user, ok := c.Locals("user").(map[string]interface{}) + if !ok || user == nil { + return 0, false + } + switch v := user["ID"].(type) { + case uint: + return v, true + case int: + return uint(v), true + case float64: + return uint(v), true + } + return 0, false +} + +// TelegramStaffStatus indica si el usuario interno autenticado ya vinculó su Telegram. +// GET /app/profile/telegram-status +func TelegramStaffStatus(c *fiber.Ctx) error { + userID, ok := staffUserID(c) + if !ok { + return c.Status(401).JSON(fiber.Map{"error": "No autenticado"}) + } + u, err := models.FindUserByID(userID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"linked": u.TelegramChatID != "", "chat_id": u.TelegramChatID}) +} + +// TelegramStaffInit genera un código de vinculación temporal para el usuario interno. +// POST /app/profile/telegram-init +func TelegramStaffInit(c *fiber.Ctx) error { + userID, ok := staffUserID(c) + if !ok { + return c.Status(401).JSON(fiber.Map{"error": "No autenticado"}) + } + token, err := models.GenerateTelegramStaffToken(userID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "No se pudo generar el código"}) + } + + configs, _ := models.GetAllTelegramConfigs() + botToken := "" + for _, cfg := range configs { + if cfg.Activo && cfg.BotToken != "" { + botToken = cfg.BotToken + break + } + } + botUsername := services.GetBotUsername(botToken) + + return c.JSON(fiber.Map{ + "token": token.Token, + "bot_username": botUsername, + "bot_link": "https://t.me/" + botUsername, + }) +} + +// TelegramStaffValidar busca en getUpdates de los bots activos el mensaje con el +// token del usuario y, si lo encuentra, vincula su chat_id. +// POST /app/profile/telegram-validar +func TelegramStaffValidar(c *fiber.Ctx) error { + userID, ok := staffUserID(c) + if !ok { + return c.Status(401).JSON(fiber.Map{"error": "No autenticado"}) + } + + tkn, err := models.GetTelegramStaffTokenByUser(userID) + if err != nil || tkn == nil { + return c.Status(400).JSON(fiber.Map{"error": "No tienes un código activo. Genera uno primero."}) + } + + configs, _ := models.GetAllTelegramConfigs() + for _, cfg := range configs { + if !cfg.Activo || cfg.BotToken == "" { + continue + } + chatID, found := searchTokenInUpdates(cfg.BotToken, tkn.Token) + if !found { + continue + } + chatIDStr := fmt.Sprintf("%d", chatID) + if err := models.UpdateUserTelegramChatID(userID, chatIDStr); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "No se pudo vincular. Intenta de nuevo."}) + } + models.DeleteTelegramStaffToken(userID) + svc := &services.TelegramService{BotToken: cfg.BotToken} + _ = svc.SendMessage(chatID, "✅ Tu Telegram quedó vinculado a tu usuario de U-Site.\n\nRecibirás aquí las tareas que te asignen y otras notificaciones personales.") + return c.JSON(fiber.Map{"ok": true, "chat_id": chatIDStr}) + } + + return c.Status(404).JSON(fiber.Map{"error": "Todavía no encontramos tu mensaje. Envía el código al bot y vuelve a intentar."}) +} diff --git a/rest/routes/hermes.go b/rest/routes/hermes.go index 3da0cad..2c75525 100644 --- a/rest/routes/hermes.go +++ b/rest/routes/hermes.go @@ -219,6 +219,7 @@ func AdminApiRoutes(api fiber.Router) { h.Post("/contabilidad/cuentas-cobro", controllers.CreateCuentaCobro) h.Put("/contabilidad/cuentas-cobro/:id", controllers.UpdateCuentaCobro) h.Delete("/contabilidad/cuentas-cobro/:id", controllers.DeleteCuentaCobro) + h.Post("/contabilidad/cuentas-cobro/:id/pagar", controllers.MarcarCuentaCobroPagada) h.Post("/contabilidad/cuentas-cobro/:id/generar-documento", controllers.GenerarDocumentoCuentaCobro) // Cuentas por pagar h.Get("/contabilidad/cuentas-pagar", controllers.GetCuentasPagar) diff --git a/rest/routes/user.go b/rest/routes/user.go index 443ab08..1c4e3f0 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -18,6 +18,9 @@ func UserRoutes(app fiber.Router) { // Perfil del usuario autenticado protected.Get("/profile", middlewares.MenuMiddleware, controllers.Profile) + protected.Get("/profile/telegram-status", controllers.TelegramStaffStatus) + protected.Post("/profile/telegram-init", controllers.TelegramStaffInit) + protected.Post("/profile/telegram-validar", controllers.TelegramStaffValidar) // Rutas de la aplicación @@ -527,6 +530,7 @@ func UserRoutes(app fiber.Router) { protected.Post("/contabilidad/cuentas-cobro", controllers.CreateCuentaCobro) protected.Put("/contabilidad/cuentas-cobro/:id", controllers.UpdateCuentaCobro) protected.Delete("/contabilidad/cuentas-cobro/:id", controllers.DeleteCuentaCobro) + protected.Post("/contabilidad/cuentas-cobro/:id/pagar", controllers.MarcarCuentaCobroPagada) protected.Post("/contabilidad/cuentas-cobro/:id/generar-documento", controllers.GenerarDocumentoCuentaCobro) // Cuentas por pagar