Compare commits

...
2 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Opus 5 1b599606f7 feat(soporte): botón de borrador que redacta con la base de conocimiento
En cada ticket, " Borrador" propone una respuesta usando los fragmentos más
parecidos de la base de conocimiento del agente de uMind que se elija en la
configuración de soporte, más el hilo de la conversación.

El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona
y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que
nadie lo lea, es la forma más rápida de perderlo.

Sin agente configurado el borrador se arma igual, solo con la conversación —
peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o
pasos que no estén en la documentación, y le pide decir qué falta preguntar
cuando no alcanza para resolver.

El cuadro de respuesta pasa a textarea: un borrador de varios renglones no
entraba en un input de una línea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:45:38 -05:00
Lizandro GuarnizoandClaude Opus 5 bfcc320b7c 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>
2026-08-17 19:43:28 -05:00
11 changed files with 337 additions and 83 deletions
+36 -14
View File
@@ -9,19 +9,26 @@ 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"`
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"`
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"`
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"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
// ClienteID se resuelve al crear el ticket a partir del remitente. Si queda
// en nil el que escribió no está registrado: es un contacto externo, y eso
// también es información (no hay un campo aparte para "externo", es esto).
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
// Categoria la pone el clasificador: error | facturacion | acceso | consulta | otro
Categoria string `json:"categoria" gorm:"column:categoria;size:40;index"`
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
}
func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
@@ -105,7 +112,7 @@ func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
var items []ProyectoTicket
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Order("created_at DESC")
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Preload("Cliente").Order("created_at DESC")
if estado != "" && estado != "todos" {
db = db.Where("estado = ?", estado)
}
@@ -142,3 +149,18 @@ func MarkTicketMessagesReadByPortal(ticketID uint) error {
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
Update("leido_portal", true).Error
}
// GetClientePorEmail busca un cliente por su dirección de correo, exacta y sin
// distinguir mayúsculas. A propósito no se busca por dominio: con gmail.com o
// hotmail.com de por medio, adivinar por dominio ata tickets al cliente
// equivocado.
func GetClientePorEmail(email string) (*Cliente, error) {
var c Cliente
err := app.Http.Database.DB.
Where("LOWER(email) = LOWER(?) OR LOWER(email_cc) = LOWER(?)", email, email).
First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
+3
View File
@@ -45,6 +45,9 @@ type SoporteWebhookConfig struct {
// clasifica cada correo nuevo antes de abrir ticket.
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
// Agente de uMind cuya base de conocimiento se usa para redactar borradores
// de respuesta. Sin agente el borrador se arma solo con la conversación.
AgenteBorradorID *uint `json:"agente_borrador_id" gorm:"column:agente_borrador_id;index"`
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
+9
View File
@@ -47,6 +47,15 @@ func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
return items, err
}
// GetTodosLosUmindAgentes lista todos los agentes de todos los tenants. Es solo
// para pantallas de staff que necesitan elegir uno (ej. de qué base de
// conocimiento salen los borradores de soporte).
func GetTodosLosUmindAgentes() ([]UmindAgente, error) {
var items []UmindAgente
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
return items, err
}
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
var a UmindAgente
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
+77
View File
@@ -0,0 +1,77 @@
package services
import (
"fmt"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// RedactarBorradorTicket propone una respuesta para un ticket, apoyándose en la
// base de conocimiento del agente de uMind que se haya elegido en la
// configuración de soporte.
//
// Devuelve un borrador, no una respuesta: no se manda a nadie. Contestarle solo
// a un cliente con lo que dijo un modelo es la forma más rápida de perderlo;
// acá lo lee una persona, lo corrige y aprieta enviar.
func RedactarBorradorTicket(ticket *models.ProyectoTicket) (string, error) {
if ticket == nil {
return "", fmt.Errorf("ticket no encontrado")
}
cfg, _ := models.GetSoporteWebhookActivo()
var conversacion strings.Builder
fmt.Fprintf(&conversacion, "Asunto: %s\n\n%s: %s\n", ticket.Titulo, ticket.AutorNombre, ticket.Descripcion)
for _, m := range ticket.Mensajes {
quien := ticket.AutorNombre
if m.EsAdmin {
quien = "Soporte"
}
fmt.Fprintf(&conversacion, "\n%s: %s\n", quien, m.Contenido)
}
sistema := `Redactás borradores de respuesta para un equipo de soporte. Los lee una
persona antes de enviarlos.
- Escribí en el mismo idioma que usó el cliente.
- Respondé lo que preguntó, directo, sin relleno ni frases de manual.
- Usá SOLO lo que esté en la documentación de referencia y en la conversación.
Si falta información para resolverlo, decilo y proponé qué preguntarle.
- Nunca inventes precios, plazos, funciones ni pasos que no estén escritos.
- Sin asunto, sin firma: solo el cuerpo del mensaje.`
if cfg != nil && strings.TrimSpace(cfg.ContextoNegocio) != "" {
sistema += "\n\nContexto del negocio:\n" + strings.TrimSpace(cfg.ContextoNegocio)
}
usuario := conversacion.String()
if doc := documentacionRelevante(cfg, ticket); doc != "" {
usuario = "Documentación de referencia:\n---\n" + doc + "\n---\n\nConversación:\n" + usuario
}
borrador, err := CompletarTextoIA("ia", sistema, usuario)
if err != nil {
return "", err
}
return strings.TrimSpace(borrador), nil
}
// documentacionRelevante busca en la base de conocimiento del agente elegido.
// Sin agente configurado devuelve vacío y el borrador se arma solo con la
// conversación — peor, pero mejor que un error.
func documentacionRelevante(cfg *models.SoporteWebhookConfig, ticket *models.ProyectoTicket) string {
if cfg == nil || cfg.AgenteBorradorID == nil || *cfg.AgenteBorradorID == 0 {
return ""
}
consulta := ticket.Titulo + "\n" + ticket.Descripcion
chunks, err := BuscarConocimiento(*cfg.AgenteBorradorID, consulta, 4)
if err != nil || len(chunks) == 0 {
return ""
}
var b strings.Builder
for _, ch := range chunks {
b.WriteString(strings.TrimSpace(ch.Contenido))
b.WriteString("\n\n")
}
return strings.TrimSpace(b.String())
}
+18 -5
View File
@@ -10,7 +10,8 @@ import (
// ClasificacionCorreo es el veredicto sobre un correo entrante.
type ClasificacionCorreo struct {
EsSoporte bool `json:"es_soporte"`
Categoria string `json:"categoria"` // soporte | comercial | factura | newsletter | notificacion | spam | otro
Categoria string `json:"categoria"` // error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
Prioridad string `json:"prioridad"` // baja | media | alta | urgente
Motivo string `json:"motivo"`
}
@@ -29,6 +30,7 @@ func ClasificarCorreoSoporte(correo CorreoSoporte, contextoNegocio string) Clasi
return ClasificacionCorreo{
EsSoporte: false,
Categoria: "newsletter",
Prioridad: "baja",
Motivo: "el correo viene marcado como masivo o automático en sus encabezados",
}
}
@@ -40,13 +42,19 @@ func ClasificarCorreoSoporte(correo CorreoSoporte, contextoNegocio string) Clasi
sistema := `Clasificás correos que llegan a la casilla de soporte de una empresa.
Respondé SOLO con este JSON, sin explicaciones ni bloques de código:
{"es_soporte": true|false, "categoria": "soporte|comercial|factura|newsletter|notificacion|spam|otro", "motivo": "una frase corta"}
{"es_soporte": true|false, "categoria": "...", "prioridad": "...", "motivo": "una frase corta"}
es_soporte = true cuando una persona pide ayuda, reporta un problema, hace una
consulta sobre un servicio contratado o responde una conversación de soporte.
es_soporte = false para newsletters, promociones, notificaciones automáticas de
plataformas, facturas de proveedores y spam.
Ante la duda, es_soporte = true: es peor ignorar a un cliente que abrir un ticket de más.`
Ante la duda, es_soporte = true: es peor ignorar a un cliente que abrir un ticket de más.
categoria: error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
prioridad: urgente si algo está caído, si hay plata o datos en riesgo, o si el
cliente dice que está bloqueado; alta si no puede trabajar pero tiene cómo
seguir; media para el resto; baja para consultas sin apuro y para lo que no es
soporte. La urgencia la da el problema, no el tono del mensaje.`
if c := strings.TrimSpace(contextoNegocio); c != "" {
sistema += "\n\nContexto del negocio:\n" + c
@@ -57,17 +65,22 @@ Ante la duda, es_soporte = true: es peor ignorar a un cliente que abrir un ticke
salida, err := CompletarTextoIA("ia", sistema, usuario)
if err != nil {
log.Printf("[Soporte] No se pudo clasificar el correo, se trata como soporte: %v", err)
return ClasificacionCorreo{EsSoporte: true, Categoria: "soporte", Motivo: "la clasificación falló"}
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "la clasificación falló"}
}
var out ClasificacionCorreo
if err := json.Unmarshal([]byte(soloJSON(salida)), &out); err != nil {
log.Printf("[Soporte] Clasificación ilegible (%q), se trata como soporte", recortar(salida, 120))
return ClasificacionCorreo{EsSoporte: true, Categoria: "soporte", Motivo: "respuesta ilegible del modelo"}
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "respuesta ilegible del modelo"}
}
if out.Categoria == "" {
out.Categoria = "otro"
}
switch out.Prioridad {
case "baja", "media", "alta", "urgente":
default:
out.Prioridad = "media"
}
return out
}
+12
View File
@@ -57,3 +57,15 @@ func TestSoloJSON(t *testing.T) {
}
}
}
func TestClasificarNormalizaPrioridad(t *testing.T) {
// El correo automático corta antes de llamar a la IA, así que es el único
// camino que se puede probar sin base de datos — y ahí la prioridad tiene
// que salir puesta igual.
cl := ClasificarCorreoSoporte(CorreoSoporte{
From: "news@x.com", Subject: "promo", Automatico: true,
}, "")
if cl.Prioridad != "baja" {
t.Errorf("Prioridad = %q, want baja", cl.Prioridad)
}
}
+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
}
}
+25
View File
@@ -217,6 +217,24 @@
<p class="text-xs text-slate-400 mt-1">Ayuda a la IA a distinguir tus casos raros. Los correos masivos se descartan por sus encabezados, sin gastar una llamada.</p>
</div>
<!-- ─── Borradores de respuesta ──────────────────────────────── -->
<div class="border-t border-slate-200 pt-5">
<p class="text-sm font-semibold text-slate-700 mb-1">Borradores de respuesta</p>
<p class="text-xs text-slate-400 mb-3">
En cada ticket aparece un botón <strong>✨ Borrador</strong> que propone una respuesta usando la base
de conocimiento del agente que elijas. <strong>Nunca la envía</strong>: cae en el cuadro de respuesta
para que la revises y la mandes vos. Sin agente elegido el borrador sale igual, pero solo con la
conversación del ticket.
</p>
<label class="block text-xs font-medium text-slate-600 mb-1">Agente de uMind</label>
<select x-model="cfg.agente_borrador_id" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
<option value="">Sin base de conocimiento</option>
<template x-for="a in agentes" :key="a.id">
<option :value="a.id" x-text="a.nombre"></option>
</template>
</select>
</div>
<div class="pt-4">
<button @click="guardar()"
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
@@ -263,8 +281,10 @@ function soporteWebhook() {
imap_horas_atras: 12,
clasificar_con_ia: false,
contexto_negocio: '',
agente_borrador_id: '',
},
admins: [],
agentes: [],
tieneImapPassword: false,
ocupado: false,
mensajeImap: '',
@@ -287,6 +307,10 @@ function soporteWebhook() {
const r = await axios.get('/app/tickets/admins');
this.admins = r.data || [];
} catch {}
try {
const r = await axios.get('/app/soporte/agentes');
this.agentes = r.data || [];
} catch {}
if (!this.cfg.api_key) {
this.cfg.api_key = this.generarClave();
}
@@ -329,6 +353,7 @@ function soporteWebhook() {
imap_horas_atras: Number(this.cfg.imap_horas_atras) || 0,
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
contexto_negocio: this.cfg.contexto_negocio || '',
agente_borrador_id: this.cfg.agente_borrador_id ? parseInt(this.cfg.agente_borrador_id) : null,
};
try {
await axios.post('/app/soporte/webhook', payload);
+28 -4
View File
@@ -38,6 +38,12 @@
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
<span x-show="t.categoria" class="badge badge-default text-xs" x-text="t.categoria"></span>
<!-- De quién es. Sin cliente resuelto es alguien de afuera: no es
un error, pero conviene que se vea distinto. -->
<span x-show="t.cliente" class="badge text-xs bg-indigo-100 text-indigo-700"
x-text="t.cliente ? '🏢 ' + t.cliente.nombre : ''"></span>
<span x-show="!t.cliente && t.origen==='email'" class="badge text-xs bg-slate-100 text-slate-500">👤 Externo</span>
</div>
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
<p class="text-xs text-slate-500 mt-0.5">
@@ -97,10 +103,16 @@
<!-- Responder -->
<div class="mt-3 flex gap-2">
<input x-model="t._reply" type="text" placeholder="Escribir respuesta..."
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none"
@keydown.enter.prevent="responder(t)"
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'">
<!-- Textarea y no input: el borrador de la IA viene en varios renglones. -->
<textarea x-model="t._reply" rows="2" placeholder="Escribir respuesta..."
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none resize-y"
@keydown.enter.exact.prevent="responder(t)"
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'"></textarea>
<button @click="redactar(t)" :disabled="t._redactando"
title="Propone una respuesta con la base de conocimiento. No la envía."
class="px-3 py-2 rounded-xl border border-slate-300 text-sm text-slate-700 disabled:opacity-50 flex-shrink-0">
<span x-text="t._redactando ? 'Redactando…' : '✨ Borrador'"></span>
</button>
<button @click="responder(t)"
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
style="background:#8eb02f"
@@ -190,6 +202,18 @@ function ticketsAdmin() {
}
},
async redactar(t) {
t._redactando = true;
try {
const { data } = await axios.post(`/app/tickets/${t.ID}/borrador`);
// Se escribe en el cuadro de respuesta, no se envía: lo revisa una persona.
t._reply = data.borrador || '';
} catch (e) {
alert(e.response?.data?.error || e.message);
}
t._redactando = false;
},
async responder(t) {
const contenido = (t._reply || '').trim();
if (!contenido) return;
+87 -50
View File
@@ -204,31 +204,32 @@ 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"`
SmtpEncryption string `json:"smtp_encryption"`
SmtpFromAddr string `json:"smtp_from_addr"`
SmtpFromName string `json:"smtp_from_name"`
ImapActivo bool `json:"imap_activo"`
ImapHost string `json:"imap_host"`
ImapPort int `json:"imap_port"`
ImapUsername string `json:"imap_username"`
ImapPassword string `json:"imap_password"`
ImapEncryption string `json:"imap_encryption"`
ImapCarpeta string `json:"imap_carpeta"`
ImapHorasAtras int `json:"imap_horas_atras"`
ClasificarConIA bool `json:"clasificar_con_ia"`
ContextoNegocio string `json:"contexto_negocio"`
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"`
ImapActivo bool `json:"imap_activo"`
ImapHost string `json:"imap_host"`
ImapPort int `json:"imap_port"`
ImapUsername string `json:"imap_username"`
ImapPassword string `json:"imap_password"`
ImapEncryption string `json:"imap_encryption"`
ImapCarpeta string `json:"imap_carpeta"`
ImapHorasAtras int `json:"imap_horas_atras"`
AgenteBorradorID *uint `json:"agente_borrador_id"`
ClasificarConIA bool `json:"clasificar_con_ia"`
ContextoNegocio string `json:"contexto_negocio"`
}
var b body
if err := c.BodyParser(&b); err != nil {
@@ -270,31 +271,32 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
}
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,
SmtpEncryption: enc,
SmtpFromAddr: b.SmtpFromAddr,
SmtpFromName: b.SmtpFromName,
ImapActivo: b.ImapActivo,
ImapHost: b.ImapHost,
ImapPort: imapPort,
ImapUsername: b.ImapUsername,
ImapPasswordEnc: passEnc,
ImapEncryption: imapEnc,
ImapCarpeta: carpeta,
ImapHorasAtras: b.ImapHorasAtras,
ClasificarConIA: b.ClasificarConIA,
ContextoNegocio: b.ContextoNegocio,
Activo: true,
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,
ImapActivo: b.ImapActivo,
ImapHost: b.ImapHost,
ImapPort: imapPort,
ImapUsername: b.ImapUsername,
ImapPasswordEnc: passEnc,
ImapEncryption: imapEnc,
ImapCarpeta: carpeta,
ImapHorasAtras: b.ImapHorasAtras,
ClasificarConIA: b.ClasificarConIA,
ContextoNegocio: b.ContextoNegocio,
AgenteBorradorID: b.AgenteBorradorID,
Activo: true,
}
cfg.ID = b.ID
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
@@ -337,3 +339,38 @@ func RevisarBuzonAhora(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true, "message": fmt.Sprintf(
"%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)})
}
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.
// No envía nada: el borrador vuelve al cuadro de respuesta para que lo revise
// una persona.
// POST /app/tickets/:ticketID/borrador
func BorradorRespuestaTicket(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("ticketID"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
ticket, err := models.GetTicketByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
}
borrador, err := services.RedactarBorradorTicket(ticket)
if err != nil {
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"borrador": borrador})
}
// GetAgentesParaBorrador lista los agentes de uMind para elegir de cuál sale la
// documentación de los borradores.
// GET /app/soporte/agentes
func GetAgentesParaBorrador(c *fiber.Ctx) error {
items, err := models.GetTodosLosUmindAgentes()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
salida := make([]fiber.Map, 0, len(items))
for _, a := range items {
salida = append(salida, fiber.Map{"id": a.ID, "nombre": a.Nombre})
}
return c.JSON(salida)
}
+2
View File
@@ -530,6 +530,7 @@ func UserRoutes(app fiber.Router) {
protected.Put("/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
protected.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
protected.Put("/tickets/:ticketID/asignar", controllers.AsignarTicket)
protected.Post("/tickets/:ticketID/borrador", controllers.BorradorRespuestaTicket)
protected.Get("/tickets/admins", controllers.GetSoporteAdmins)
// Configuración webhook de soporte
@@ -538,6 +539,7 @@ func UserRoutes(app fiber.Router) {
protected.Post("/soporte/webhook", controllers.SaveSoporteWebhookConfig)
protected.Post("/soporte/webhook/probar-imap", controllers.ProbarImapSoporte)
protected.Post("/soporte/webhook/revisar-buzon", controllers.RevisarBuzonAhora)
protected.Get("/soporte/agentes", controllers.GetAgentesParaBorrador)
// Configuración de notificaciones
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)