Compare commits
2
Commits
6afad025c2
...
1b599606f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b599606f7 | ||
|
|
bfcc320b7c |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user