feat(umind): el agente programa recordatorios, y solo para su dueño
"Avisame el 15 de marzo que vence la póliza de Acme, y todos los años" queda programado desde la conversación, visible y cancelable en el panel. Tres tools: programar, listar, cancelar. El aviso vuelve por donde se pidió — Telegram a ese chat, correo a esa casilla — o al dueño del espacio por correo y campanita. Nunca a una dirección que dicte la conversación: eso sería un cañón de spam con destinatario libre. Y las tools de aviso solo se le OFRECEN al modelo cuando la sesión es interna: el chat de prueba del panel (que corre autenticado) o un canal marcado como línea privada del dueño. En el widget público escribe cualquiera, y cualquiera no puede programarle recordatorios ni gastarle el plan a otro. El gate está en dos capas: la tool no se declara, y si igual la pide, el ejecutor la rechaza. Tres detalles que se pagan una sola vez: - El cron corre en memoria del proceso, sin lock distribuido: con dos instancias cada aviso saldría dos veces. El reclamo es un UPDATE condicional — la base ya es el árbitro, no hace falta traer otro. - Si el servidor estuvo caído, una repetición diaria se saltea los ciclos perdidos en vez de disparar diez avisos viejos de golpe. - Un fallo de SMTP devuelve el aviso a pendiente: una caída de correo no puede perder un vencimiento de póliza. Una fecha sin hora se entrega a las 9, no a medianoche, que es cuando nadie mira el teléfono. De paso: los archivos de los espacios uMind nunca se sirven por el estático de /uploads. El guard genérico solo sabe si hay sesión de panel, no de quién es el archivo — salen por su endpoint, que sí valida propiedad. Cerrado por construcción y no por acordarse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9da2a057f2
commit
b2e2e83fe8
@@ -11,9 +11,9 @@ import (
|
||||
type ClienteDocumento struct {
|
||||
gorm.Model
|
||||
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;not null;index"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre"` // nombre descriptivo del doc
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta relativa en disco
|
||||
OriginalName string `json:"original_name" gorm:"column:original_name"` // nombre original del archivo
|
||||
Nombre string `json:"nombre" gorm:"column:nombre"` // nombre descriptivo del doc
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"` // ruta relativa en disco
|
||||
OriginalName string `json:"original_name" gorm:"column:original_name"` // nombre original del archivo
|
||||
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
|
||||
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"` // bytes
|
||||
FechaExpedicion *time.Time `json:"fecha_expedicion" gorm:"column:fecha_expedicion"` // opcional
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
type CloudflareConfig struct {
|
||||
gorm.Model
|
||||
// Tipo de autenticación: "token" (defecto) o "global_key"
|
||||
AuthType string `json:"auth_type" gorm:"column:auth_type;type:varchar(20);default:'token'"`
|
||||
AuthType string `json:"auth_type" gorm:"column:auth_type;type:varchar(20);default:'token'"`
|
||||
// API Token (AuthType=token) o Global API Key (AuthType=global_key)
|
||||
APIToken string `json:"api_token" gorm:"column:api_token;type:text;not null"`
|
||||
APIToken string `json:"api_token" gorm:"column:api_token;type:text;not null"`
|
||||
// Email de la cuenta (requerido solo para AuthType=global_key)
|
||||
Email string `json:"email" gorm:"column:email;type:text"`
|
||||
Email string `json:"email" gorm:"column:email;type:text"`
|
||||
// Account ID principal (opcional, para endpoints de cuenta)
|
||||
AccountID string `json:"account_id" gorm:"column:account_id;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
type ConxDb struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||
Host string `json:"host" gorm:"column:host"` // vacío = usa Servidor.IpServidor
|
||||
Host string `json:"host" gorm:"column:host"` // vacío = usa Servidor.IpServidor
|
||||
Puerto string `json:"puerto" gorm:"column:puerto"`
|
||||
Usuario string `json:"usuario" gorm:"column:usuario"`
|
||||
Password string `json:"password" gorm:"column:password"`
|
||||
|
||||
@@ -19,7 +19,7 @@ type DocPagina struct {
|
||||
Slug string `json:"slug" gorm:"column:slug;not null;uniqueIndex:idx_saas_slug"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
TipoContenido string `json:"tipo_contenido" gorm:"column:tipo_contenido;default:'markdown'"` // markdown | html
|
||||
Visibilidad string `json:"visibilidad" gorm:"column:visibilidad;default:'public'"` // public | private
|
||||
Visibilidad string `json:"visibilidad" gorm:"column:visibilidad;default:'public'"` // public | private
|
||||
Orden int `json:"orden" gorm:"column:orden;default:0"`
|
||||
Publicado bool `json:"publicado" gorm:"column:publicado;default:false"`
|
||||
CreadoPor uint `json:"creado_por" gorm:"column:creado_por"`
|
||||
|
||||
@@ -12,32 +12,32 @@ import (
|
||||
type LandingSession struct {
|
||||
gorm.Model
|
||||
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
|
||||
UserName string `json:"user_name" gorm:"column:user_name"`
|
||||
UserEmail string `json:"user_email" gorm:"column:user_email"`
|
||||
UserPhone string `json:"user_phone" gorm:"column:user_phone"`
|
||||
UserCompany string `json:"user_company" gorm:"column:user_company"`
|
||||
UserJob string `json:"user_job" gorm:"column:user_job"`
|
||||
UserWebsite string `json:"user_website" gorm:"column:user_website"`
|
||||
UserAddress string `json:"user_address" gorm:"column:user_address"`
|
||||
SocialMedia string `json:"social_media" gorm:"column:social_media"`
|
||||
LogoUrl string `json:"logo_url" gorm:"column:logo_url"`
|
||||
UserName string `json:"user_name" gorm:"column:user_name"`
|
||||
UserEmail string `json:"user_email" gorm:"column:user_email"`
|
||||
UserPhone string `json:"user_phone" gorm:"column:user_phone"`
|
||||
UserCompany string `json:"user_company" gorm:"column:user_company"`
|
||||
UserJob string `json:"user_job" gorm:"column:user_job"`
|
||||
UserWebsite string `json:"user_website" gorm:"column:user_website"`
|
||||
UserAddress string `json:"user_address" gorm:"column:user_address"`
|
||||
SocialMedia string `json:"social_media" gorm:"column:social_media"`
|
||||
LogoUrl string `json:"logo_url" gorm:"column:logo_url"`
|
||||
// Historial de mensajes almacenado como JSON
|
||||
AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"`
|
||||
HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"`
|
||||
// status: in_progress | generated | paid | expired
|
||||
Status string `json:"status" gorm:"column:status;default:'in_progress'"`
|
||||
BoldLinkID string `json:"bold_link_id" gorm:"column:bold_link_id"`
|
||||
BoldPaid bool `json:"bold_paid" gorm:"column:bold_paid;default:false"`
|
||||
BoldPaymentID string `json:"bold_payment_id" gorm:"column:bold_payment_id"`
|
||||
PriceCOP int64 `json:"price_cop" gorm:"column:price_cop;default:49900"`
|
||||
VcardIncluded bool `json:"vcard_included" gorm:"column:vcard_included;default:false"`
|
||||
Status string `json:"status" gorm:"column:status;default:'in_progress'"`
|
||||
BoldLinkID string `json:"bold_link_id" gorm:"column:bold_link_id"`
|
||||
BoldPaid bool `json:"bold_paid" gorm:"column:bold_paid;default:false"`
|
||||
BoldPaymentID string `json:"bold_payment_id" gorm:"column:bold_payment_id"`
|
||||
PriceCOP int64 `json:"price_cop" gorm:"column:price_cop;default:49900"`
|
||||
VcardIncluded bool `json:"vcard_included" gorm:"column:vcard_included;default:false"`
|
||||
}
|
||||
|
||||
func (LandingSession) TableName() string { return "landing_sessions" }
|
||||
|
||||
// LandingMessage es un mensaje del chat (serializado en AnswersJSON).
|
||||
type LandingMessage struct {
|
||||
Role string `json:"role"` // "assistant" | "user"
|
||||
Role string `json:"role"` // "assistant" | "user"
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
|
||||
+11
-10
@@ -11,11 +11,12 @@ import (
|
||||
// Define qué canales activar para cada combinación evento × destinatario.
|
||||
//
|
||||
// Eventos disponibles (extensibles):
|
||||
// ticket_nuevo → admin recibe cuando el cliente abre un ticket
|
||||
// ticket_respuesta_cliente → admin recibe cuando el cliente responde
|
||||
// ticket_respuesta_admin → portal_user recibe cuando admin responde
|
||||
// factura_emitida → portal_user recibe cuando se emite factura
|
||||
// avance_publicado → portal_user recibe cuando se publica avance
|
||||
//
|
||||
// ticket_nuevo → admin recibe cuando el cliente abre un ticket
|
||||
// ticket_respuesta_cliente → admin recibe cuando el cliente responde
|
||||
// ticket_respuesta_admin → portal_user recibe cuando admin responde
|
||||
// factura_emitida → portal_user recibe cuando se emite factura
|
||||
// avance_publicado → portal_user recibe cuando se publica avance
|
||||
//
|
||||
// Destinatarios: "admin" | "portal_user"
|
||||
type NotifEventoConfig struct {
|
||||
@@ -168,11 +169,11 @@ func SaveServidorAlertaUmbral(cfg *ServidorAlertaUmbral) error {
|
||||
return db.Create(cfg).Error
|
||||
}
|
||||
return db.Model(&existing).Updates(map[string]interface{}{
|
||||
"activo": cfg.Activo,
|
||||
"minutos_sin_ping": cfg.MinutosSinPing,
|
||||
"umbral_cpu": cfg.UmbralCPU,
|
||||
"umbral_ram": cfg.UmbralRAM,
|
||||
"umbral_disco": cfg.UmbralDisco,
|
||||
"activo": cfg.Activo,
|
||||
"minutos_sin_ping": cfg.MinutosSinPing,
|
||||
"umbral_cpu": cfg.UmbralCPU,
|
||||
"umbral_ram": cfg.UmbralRAM,
|
||||
"umbral_disco": cfg.UmbralDisco,
|
||||
"dias_ante_vencimiento": cfg.DiasAnteVencimiento,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ type OssApi struct {
|
||||
BucketName string `gorm:"not null" json:"bucket_name"` // Nombre del bucket o access point
|
||||
Region string `gorm:"size:50" json:"region"` // Región, opcional
|
||||
IsActive bool `gorm:"default:true" json:"is_active"` // Activar o desactivar config
|
||||
PublicURL string `gorm:"size:255" json:"public_url"` // URL pública para S3 (opcional)
|
||||
PublicURL string `gorm:"size:255" json:"public_url"` // URL pública para S3 (opcional)
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func GetAllOssApi(limit, offset int, search, provider, estado, sortBy, sortDir,
|
||||
sortDir = "desc"
|
||||
}
|
||||
|
||||
if err := db.Order(sortBy+" "+sortDir).Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
if err := db.Order(sortBy + " " + sortDir).Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
||||
log.Printf("Error retrieving OssApi: %v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ func (PartnerRecurso) TableName() string { return "partner_recursos" }
|
||||
|
||||
type PartnerComunicado struct {
|
||||
gorm.Model
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||
Archivo string `json:"archivo" gorm:"column:archivo"`
|
||||
NombreOrig string `json:"nombre_orig" gorm:"column:nombre_orig"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (PartnerComunicado) TableName() string { return "partner_comunicados" }
|
||||
|
||||
@@ -76,8 +76,8 @@ func TestProyectoTicketAsignadoNil(t *testing.T) {
|
||||
|
||||
func TestTicketMensajeDefaults(t *testing.T) {
|
||||
msg := TicketMensaje{
|
||||
TicketID: 1,
|
||||
Contenido: "Hola",
|
||||
TicketID: 1,
|
||||
Contenido: "Hola",
|
||||
AutorNombre: "Admin",
|
||||
}
|
||||
if msg.EsAdmin {
|
||||
|
||||
@@ -14,7 +14,7 @@ type QueryHistory struct {
|
||||
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
|
||||
UserID uint `json:"user_id" gorm:"column:user_id;index;default:0"`
|
||||
SQL string `json:"sql" gorm:"column:sql;type:text"`
|
||||
Status string `json:"status" gorm:"column:status"` // ok | error
|
||||
Status string `json:"status" gorm:"column:status"` // ok | error
|
||||
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
||||
RowsAffect int64 `json:"rows_affect" gorm:"column:rows_affect"`
|
||||
DurationMs int64 `json:"duration_ms" gorm:"column:duration_ms"`
|
||||
|
||||
@@ -107,15 +107,15 @@ func DeleteServidor(servidor Servidor) error {
|
||||
// ── Historial de métricas ─────────────────────────────────────────────────────
|
||||
|
||||
type ServidorMetricasHistory struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ServidorID uint `json:"servidor_id" gorm:"column:servidor_id;index"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"`
|
||||
CpuPct float64 `json:"cpu_pct" gorm:"column:cpu_pct"`
|
||||
RamPct float64 `json:"ram_pct" gorm:"column:ram_pct"`
|
||||
SwapPct float64 `json:"swap_pct" gorm:"column:swap_pct"`
|
||||
DiscoPct float64 `json:"disco_pct" gorm:"column:disco_pct"`
|
||||
TCPConns int `json:"tcp_conns" gorm:"column:tcp_conns"`
|
||||
Load1 float64 `json:"load1" gorm:"column:load1"`
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ServidorID uint `json:"servidor_id" gorm:"column:servidor_id;index"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"`
|
||||
CpuPct float64 `json:"cpu_pct" gorm:"column:cpu_pct"`
|
||||
RamPct float64 `json:"ram_pct" gorm:"column:ram_pct"`
|
||||
SwapPct float64 `json:"swap_pct" gorm:"column:swap_pct"`
|
||||
DiscoPct float64 `json:"disco_pct" gorm:"column:disco_pct"`
|
||||
TCPConns int `json:"tcp_conns" gorm:"column:tcp_conns"`
|
||||
Load1 float64 `json:"load1" gorm:"column:load1"`
|
||||
}
|
||||
|
||||
func (ServidorMetricasHistory) TableName() string { return "servidor_metricas_history" }
|
||||
|
||||
+39
-39
@@ -16,11 +16,11 @@ type StatuspageStatus struct {
|
||||
}
|
||||
|
||||
type StatuspagePage struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type StatuspageComponent struct {
|
||||
@@ -39,50 +39,50 @@ type StatuspageComponent struct {
|
||||
}
|
||||
|
||||
type StatuspageIncidentUpdate struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type StatuspageIncident struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // investigating, identified, monitoring, resolved, postmortem
|
||||
Impact string `json:"impact"` // none, minor, major, critical
|
||||
PageID string `json:"page_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MonitoringAt *time.Time `json:"monitoring_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at"`
|
||||
ShortLink string `json:"shortlink"`
|
||||
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
||||
AffectedComponents []StatuspageComponent `json:"components"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // investigating, identified, monitoring, resolved, postmortem
|
||||
Impact string `json:"impact"` // none, minor, major, critical
|
||||
PageID string `json:"page_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MonitoringAt *time.Time `json:"monitoring_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at"`
|
||||
ShortLink string `json:"shortlink"`
|
||||
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
||||
AffectedComponents []StatuspageComponent `json:"components"`
|
||||
}
|
||||
|
||||
type StatuspageScheduledMaintenance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // scheduled, in_progress, verifying, completed
|
||||
Impact string `json:"impact"`
|
||||
PageID string `json:"page_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ScheduledFor time.Time `json:"scheduled_for"`
|
||||
ScheduledUntil time.Time `json:"scheduled_until"`
|
||||
ShortLink string `json:"shortlink"`
|
||||
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
||||
AffectedComponents []StatuspageComponent `json:"components"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // scheduled, in_progress, verifying, completed
|
||||
Impact string `json:"impact"`
|
||||
PageID string `json:"page_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ScheduledFor time.Time `json:"scheduled_for"`
|
||||
ScheduledUntil time.Time `json:"scheduled_until"`
|
||||
ShortLink string `json:"shortlink"`
|
||||
IncidentUpdates []StatuspageIncidentUpdate `json:"incident_updates"`
|
||||
AffectedComponents []StatuspageComponent `json:"components"`
|
||||
}
|
||||
|
||||
// StatuspageSummary es la respuesta completa del endpoint /api/v2/summary.json
|
||||
type StatuspageSummary struct {
|
||||
Page StatuspagePage `json:"page"`
|
||||
Status StatuspageStatus `json:"status"`
|
||||
Components []StatuspageComponent `json:"components"`
|
||||
Incidents []StatuspageIncident `json:"incidents"`
|
||||
ScheduledMaintenances []StatuspageScheduledMaintenance `json:"scheduled_maintenances"`
|
||||
Page StatuspagePage `json:"page"`
|
||||
Status StatuspageStatus `json:"status"`
|
||||
Components []StatuspageComponent `json:"components"`
|
||||
Incidents []StatuspageIncident `json:"incidents"`
|
||||
ScheduledMaintenances []StatuspageScheduledMaintenance `json:"scheduled_maintenances"`
|
||||
}
|
||||
|
||||
// FetchStatuspageSummary consulta la API pública de Atlassian Statuspage.
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
type TelegramAgentHistory struct {
|
||||
gorm.Model
|
||||
ChatID int64 `json:"chat_id" gorm:"column:chat_id;index;not null"`
|
||||
Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool
|
||||
Role string `json:"role" gorm:"column:role;not null"` // user | assistant | tool
|
||||
Content string `json:"content" gorm:"column:content;type:text;not null"`
|
||||
// ToolName y ToolResult se usan para mensajes de tipo "tool"
|
||||
ToolName string `json:"tool_name" gorm:"column:tool_name"`
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UmindAviso es un recordatorio que el propio dueño le pidió al agente:
|
||||
// "avisame el 15 de marzo que vence la póliza de Acme, y todos los años".
|
||||
// Vuelve por donde se pidió — nunca a una dirección que dicte la conversación.
|
||||
type UmindAviso struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index;not null"`
|
||||
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"` // desnormalizado: listar por espacio sin join
|
||||
Titulo string `json:"titulo" gorm:"column:titulo;size:200;not null"`
|
||||
Detalle string `json:"detalle" gorm:"column:detalle;type:text"`
|
||||
// ProximoAt es cuándo toca. En repeticiones se corre solo hacia adelante.
|
||||
ProximoAt time.Time `json:"proximo_at" gorm:"column:proximo_at;index;not null"`
|
||||
Repetir string `json:"repetir" gorm:"column:repetir;size:20"` // "" | diario | semanal | mensual | anual
|
||||
// Destino es el sessionID de donde salió el pedido ("tg:123", "mail:a@b").
|
||||
// Vacío = al dueño del espacio por correo.
|
||||
Destino string `json:"destino" gorm:"column:destino;size:120"`
|
||||
Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente';index"`
|
||||
UltimoEnvioAt *time.Time `json:"ultimo_envio_at" gorm:"column:ultimo_envio_at"`
|
||||
Error string `json:"error" gorm:"column:error;type:text"`
|
||||
}
|
||||
|
||||
func (UmindAviso) TableName() string { return "umind_avisos" }
|
||||
|
||||
// UmindAvisoMax acota cuántos avisos pendientes puede tener un agente. Sin
|
||||
// tope, una conversación en bucle programa mil recordatorios.
|
||||
const UmindAvisoMax = 50
|
||||
|
||||
func CreateUmindAviso(a *UmindAviso) error {
|
||||
var n int64
|
||||
app.Http.Database.DB.Model(&UmindAviso{}).
|
||||
Where("agente_id = ? AND estado = ? AND deleted_at IS NULL", a.AgenteID, "pendiente").Count(&n)
|
||||
if n >= UmindAvisoMax {
|
||||
return fmt.Errorf("este agente ya tiene el máximo de %d avisos programados", UmindAvisoMax)
|
||||
}
|
||||
return app.Http.Database.DB.Create(a).Error
|
||||
}
|
||||
|
||||
func GetUmindAvisosByAgente(agenteID uint) ([]UmindAviso, error) {
|
||||
var items []UmindAviso
|
||||
err := app.Http.Database.DB.Where("agente_id = ?", agenteID).
|
||||
Order("proximo_at ASC").Limit(100).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUmindAvisoByID(id uint) (*UmindAviso, error) {
|
||||
var a UmindAviso
|
||||
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// CancelarUmindAviso solo cancela los pendientes: cancelar uno ya enviado no
|
||||
// significa nada y ocultaría un error de identificación del agente.
|
||||
func CancelarUmindAviso(id, agenteID uint) error {
|
||||
return app.Http.Database.DB.Model(&UmindAviso{}).
|
||||
Where("id = ? AND agente_id = ? AND estado = ?", id, agenteID, "pendiente").
|
||||
Update("estado", "cancelado").Error
|
||||
}
|
||||
|
||||
func DeleteUmindAviso(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindAviso{}, id).Error
|
||||
}
|
||||
|
||||
// GetUmindAvisosVencidos devuelve los que ya deberían haber salido.
|
||||
func GetUmindAvisosVencidos() ([]UmindAviso, error) {
|
||||
var items []UmindAviso
|
||||
err := app.Http.Database.DB.
|
||||
Where("estado = ? AND proximo_at <= ? AND deleted_at IS NULL", "pendiente", time.Now()).
|
||||
Limit(200).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ReclamarAviso marca el aviso como "enviando" solo si seguía pendiente, y
|
||||
// dice si el reclamo fue de este proceso. El cron corre en memoria del
|
||||
// proceso, sin lock distribuido: con dos instancias del binario levantadas
|
||||
// cada aviso saldría dos veces. Este UPDATE condicional es el lock — la base
|
||||
// ya es el árbitro, no hace falta traer otro.
|
||||
func ReclamarAviso(id uint) bool {
|
||||
res := app.Http.Database.DB.Model(&UmindAviso{}).
|
||||
Where("id = ? AND estado = ?", id, "pendiente").
|
||||
Update("estado", "enviando")
|
||||
return res.Error == nil && res.RowsAffected == 1
|
||||
}
|
||||
|
||||
// ReprogramarAviso corre la fecha hacia adelante según la repetición y lo
|
||||
// deja pendiente otra vez. Si no repite, queda enviado.
|
||||
func ReprogramarAviso(a *UmindAviso) error {
|
||||
ahora := time.Now()
|
||||
updates := map[string]interface{}{"ultimo_envio_at": &ahora, "error": ""}
|
||||
|
||||
proximo := siguienteFecha(a.ProximoAt, a.Repetir)
|
||||
if proximo.IsZero() {
|
||||
updates["estado"] = "enviado"
|
||||
} else {
|
||||
// Si el servidor estuvo caído varios ciclos, se salta los perdidos en
|
||||
// vez de disparar una ráfaga de avisos viejos.
|
||||
for !proximo.After(ahora) {
|
||||
proximo = siguienteFecha(proximo, a.Repetir)
|
||||
}
|
||||
updates["estado"] = "pendiente"
|
||||
updates["proximo_at"] = proximo
|
||||
}
|
||||
return app.Http.Database.DB.Model(&UmindAviso{}).Where("id = ?", a.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func siguienteFecha(desde time.Time, repetir string) time.Time {
|
||||
switch repetir {
|
||||
case "diario":
|
||||
return desde.AddDate(0, 0, 1)
|
||||
case "semanal":
|
||||
return desde.AddDate(0, 0, 7)
|
||||
case "mensual":
|
||||
return desde.AddDate(0, 1, 0)
|
||||
case "anual":
|
||||
return desde.AddDate(1, 0, 0)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// MarcarAvisoFallido lo devuelve a pendiente para el próximo ciclo: una caída
|
||||
// de SMTP no puede perder un vencimiento de póliza.
|
||||
func MarcarAvisoFallido(id uint, motivo string) {
|
||||
app.Http.Database.DB.Model(&UmindAviso{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"estado": "pendiente", "error": motivo})
|
||||
}
|
||||
|
||||
// SiguienteFechaAviso se exporta solo para poder probar el cálculo de
|
||||
// repeticiones sin base de datos de por medio.
|
||||
func SiguienteFechaAviso(desde time.Time, repetir string) time.Time {
|
||||
return siguienteFecha(desde, repetir)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -50,6 +51,10 @@ type UmindCanal struct {
|
||||
// no pasa el filtro se marca leído y no se contesta — el agente decidió,
|
||||
// no está pendiente.
|
||||
FiltroRemitentes string `json:"filtro_remitentes" gorm:"column:filtro_remitentes;type:text"`
|
||||
// EsInterno marca que este canal es la línea privada del dueño y no una
|
||||
// puerta a sus clientes. En un canal interno el agente ejecuta acciones
|
||||
// directo; en uno público toda acción queda esperando aprobación.
|
||||
EsInterno bool `json:"es_interno" gorm:"column:es_interno;default:false"`
|
||||
}
|
||||
|
||||
// MarcarRevisionCanal deja constancia de que la casilla se acaba de revisar.
|
||||
@@ -123,3 +128,40 @@ func UpdateUmindCanal(id uint, updates map[string]interface{}) error {
|
||||
func DeleteUmindCanal(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UmindCanal{}, id).Error
|
||||
}
|
||||
|
||||
// GetUmindCanalPorTipo devuelve el canal activo de ese tipo del agente.
|
||||
func GetUmindCanalPorTipo(agenteID uint, tipo string) (*UmindCanal, error) {
|
||||
var c UmindCanal
|
||||
err := app.Http.Database.DB.
|
||||
Where("agente_id = ? AND tipo = ? AND activo = ?", agenteID, tipo, true).
|
||||
First(&c).Error
|
||||
return &c, err
|
||||
}
|
||||
|
||||
// EsSesionInterna dice si esa conversación es con el dueño y no con un
|
||||
// cliente suyo. Es lo que decide si el agente puede ejecutar acciones
|
||||
// directamente o si tienen que esperar aprobación: en el widget público
|
||||
// escribe cualquiera, y "cualquiera" no puede programar avisos ni gastar
|
||||
// el plan del dueño.
|
||||
func EsSesionInterna(agenteID uint, sessionID string) bool {
|
||||
// El chat de prueba del panel: del otro lado hay una sesión autenticada.
|
||||
if strings.HasPrefix(sessionID, "panel:") {
|
||||
return true
|
||||
}
|
||||
var tipo string
|
||||
switch {
|
||||
case strings.HasPrefix(sessionID, "tg:"):
|
||||
tipo = "telegram"
|
||||
case strings.HasPrefix(sessionID, "mail:"):
|
||||
tipo = "correo"
|
||||
case strings.HasPrefix(sessionID, "wa:"):
|
||||
tipo = "whatsapp"
|
||||
default:
|
||||
return false // widget web: siempre público
|
||||
}
|
||||
canal, err := GetUmindCanalPorTipo(agenteID, tipo)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return canal.EsInterno
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
|
||||
type UrlMonitor struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||
URL string `json:"url" gorm:"column:url;size:500"`
|
||||
IntervaloMin int `json:"intervalo_min" gorm:"column:intervalo_min;default:5"`
|
||||
TimeoutSeg int `json:"timeout_seg" gorm:"column:timeout_seg;default:10"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||
URL string `json:"url" gorm:"column:url;size:500"`
|
||||
IntervaloMin int `json:"intervalo_min" gorm:"column:intervalo_min;default:5"`
|
||||
TimeoutSeg int `json:"timeout_seg" gorm:"column:timeout_seg;default:10"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
// Estado actual
|
||||
UltimoStatus int `json:"ultimo_status" gorm:"column:ultimo_status"`
|
||||
UltimaLatMs int64 `json:"ultima_lat_ms" gorm:"column:ultima_lat_ms"`
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VcardApiConfig almacena las credenciales para conectar con la API Admin del
|
||||
// sistema VCard externo (Laravel + Sanctum, prefijo /api/admin/*).
|
||||
type VcardApiConfig struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
||||
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"`
|
||||
BearerToken string `json:"bearer_token" gorm:"column:bearer_token;type:text;not null"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
||||
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"`
|
||||
BearerToken string `json:"bearer_token" gorm:"column:bearer_token;type:text;not null"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (VcardApiConfig) TableName() string { return "vcard_api_configs" }
|
||||
|
||||
func GetVcardApiConfig() (*VcardApiConfig, error) {
|
||||
var cfg VcardApiConfig
|
||||
if err := app.Http.Database.DB.First(&cfg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
var cfg VcardApiConfig
|
||||
if err := app.Http.Database.DB.First(&cfg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func UpsertVcardApiConfig(nombre, baseURL, bearerToken string, activo bool) (*VcardApiConfig, error) {
|
||||
var cfg VcardApiConfig
|
||||
err := app.Http.Database.DB.First(&cfg).Error
|
||||
if err != nil {
|
||||
cfg = VcardApiConfig{
|
||||
Nombre: nombre,
|
||||
BaseURL: baseURL,
|
||||
BearerToken: bearerToken,
|
||||
Activo: activo,
|
||||
}
|
||||
if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
cfg.Nombre = nombre
|
||||
cfg.BaseURL = baseURL
|
||||
if bearerToken != "" {
|
||||
cfg.BearerToken = bearerToken
|
||||
}
|
||||
cfg.Activo = activo
|
||||
if err := app.Http.Database.DB.Save(&cfg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
var cfg VcardApiConfig
|
||||
err := app.Http.Database.DB.First(&cfg).Error
|
||||
if err != nil {
|
||||
cfg = VcardApiConfig{
|
||||
Nombre: nombre,
|
||||
BaseURL: baseURL,
|
||||
BearerToken: bearerToken,
|
||||
Activo: activo,
|
||||
}
|
||||
if createErr := app.Http.Database.DB.Create(&cfg).Error; createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
cfg.Nombre = nombre
|
||||
cfg.BaseURL = baseURL
|
||||
if bearerToken != "" {
|
||||
cfg.BearerToken = bearerToken
|
||||
}
|
||||
cfg.Activo = activo
|
||||
if err := app.Http.Database.DB.Save(&cfg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
type VcfVcard struct {
|
||||
gorm.Model
|
||||
Vcf string `json:"vcf" gorm:"column:vcf"`
|
||||
Vcf string `json:"vcf" gorm:"column:vcf"`
|
||||
UsuarioID string `json:"usuario" gorm:"column:usuario"`
|
||||
Email string `json:"email" gorm:"column:email"`
|
||||
VcardID string `json:"vcard_id" gorm:"column:vcard_id"`
|
||||
|
||||
@@ -88,11 +88,11 @@ func GetWebSmsLogs(limit int) ([]WebSmsLog, error) {
|
||||
|
||||
type WebSmsWebhookLog struct {
|
||||
gorm.Model
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20;index"` // delivery | click | incoming
|
||||
MsgID string `json:"msg_id" gorm:"column:msg_id;size:100;index"`
|
||||
Para string `json:"para" gorm:"column:para;size:30"`
|
||||
Status string `json:"status" gorm:"column:status;size:30"`
|
||||
Raw string `json:"raw" gorm:"column:raw;type:text"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20;index"` // delivery | click | incoming
|
||||
MsgID string `json:"msg_id" gorm:"column:msg_id;size:100;index"`
|
||||
Para string `json:"para" gorm:"column:para;size:30"`
|
||||
Status string `json:"status" gorm:"column:status;size:30"`
|
||||
Raw string `json:"raw" gorm:"column:raw;type:text"`
|
||||
}
|
||||
|
||||
func (WebSmsWebhookLog) TableName() string { return "websms_webhook_log" }
|
||||
|
||||
Reference in New Issue
Block a user