feat(soporte): el ticket de correo ya sabe de quién es, y llega con prioridad

Un ticket que entraba por correo guardaba la dirección como texto suelto: no se
podía cruzar con nada, el cliente no lo veía en su portal y no había forma de
pedir "todos los tickets de Acme". Ahora al crearlo se resuelve el remitente —
primero contra los usuarios del portal, si no contra el correo del cliente— y el
ticket queda atado a quien escribió. El que no matchea queda como contacto
externo, que también es información.

Solo coincidencia exacta de dirección, nunca por dominio: con gmail.com de por
medio, adivinar ata el ticket al cliente equivocado.

Y la llamada de IA que ya clasificaba el correo ahora devuelve también prioridad
y categoría en el mismo JSON: sin costo ni latencia extra. El campo prioridad
existía desde siempre y nadie lo llenaba. La urgencia la define el problema
descrito, no el tono del mensaje — está dicho en el prompt.

En la lista de tickets se ven los tres datos nuevos: cliente (o "externo"),
categoría y la prioridad que ya se mostraba.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 19:43:28 -05:00
co-authored by Claude Opus 5
parent 6afad025c2
commit bfcc320b7c
5 changed files with 112 additions and 29 deletions
+40 -10
View File
@@ -89,16 +89,6 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
return true
}
// Filtro con IA: solo para tickets nuevos. Una respuesta a un hilo ya
// existente es soporte por definición, no hace falta preguntárselo a nadie.
if cfg != nil && cfg.ClasificarConIA {
if cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio); !cl.EsSoporte {
log.Printf("[Soporte] Descartado por el filtro (%s): %q de %s — %s",
cl.Categoria, e.Subject, fromEmail, cl.Motivo)
return false
}
}
ticket := &models.ProyectoTicket{
AutorNombre: fromName,
EmailFrom: fromEmail,
@@ -108,6 +98,24 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
Origen: "email",
MessageID: e.MessageID,
}
// Filtro y triage con IA: solo para tickets nuevos. Una respuesta a un hilo
// ya existente es soporte por definición, no hace falta preguntárselo a nadie.
if cfg != nil && cfg.ClasificarConIA {
cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio)
if !cl.EsSoporte {
log.Printf("[Soporte] Descartado por el filtro (%s): %q de %s — %s",
cl.Categoria, e.Subject, fromEmail, cl.Motivo)
return false
}
ticket.Categoria = cl.Categoria
ticket.Prioridad = cl.Prioridad
}
// De quién es: sin esto un ticket de correo es un texto suelto que no se
// puede cruzar con nada. Si no matchea nadie, queda como contacto externo.
resolverRemitente(ticket, fromEmail)
if cfg != nil && cfg.AsignarA != nil {
ticket.AsignadoA = cfg.AsignarA
}
@@ -242,3 +250,25 @@ func esCorreoDuplicado(err error) bool {
}
return strings.Contains(strings.ToLower(err.Error()), "duplicate key value")
}
// resolverRemitente ata el ticket a quien escribió: primero por usuario del
// portal (que además le deja ver el ticket desde su portal) y si no, por el
// correo del cliente. Solo coincidencia exacta de dirección — ver el comentario
// de models.GetClientePorEmail sobre por qué no se busca por dominio.
func resolverRemitente(ticket *models.ProyectoTicket, email string) {
if email == "" {
return
}
if pu, err := models.GetPortalUserByEmail(email); err == nil && pu != nil {
id := pu.ID
ticket.PortalUserID = &id
if ids := models.GetClienteIDsForPortalUser(pu); len(ids) == 1 {
ticket.ClienteID = &ids[0]
}
return
}
if cli, err := models.GetClientePorEmail(email); err == nil && cli != nil {
id := cli.ID
ticket.ClienteID = &id
}
}