"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>
167 lines
6.2 KiB
Go
167 lines
6.2 KiB
Go
package models
|
|
|
|
import (
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// DocPagina es el contenido real de documentación.
|
|
// Visibilidad "public" → accesible sin login.
|
|
// Visibilidad "private" → requiere login Y tener al menos uno de los roles asignados.
|
|
// TipoContenido "markdown" | "html" determina cómo se renderiza el campo Contenido.
|
|
type DocPagina struct {
|
|
gorm.Model
|
|
SaasID uint `json:"saas_id" gorm:"column:saas_id;not null"`
|
|
Saas SaasProducto `json:"saas" gorm:"foreignKey:SaasID"`
|
|
CategoriaID uint `json:"categoria_id" gorm:"column:categoria_id;not null"`
|
|
Categoria DocCategoria `json:"categoria" gorm:"foreignKey:CategoriaID"`
|
|
Titulo string `json:"titulo" gorm:"column:titulo;not null"`
|
|
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
|
|
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"`
|
|
// Roles que pueden ver esta página cuando visibilidad = "private"
|
|
Roles []Roles `json:"roles" gorm:"many2many:doc_pagina_roles"`
|
|
}
|
|
|
|
func (DocPagina) TableName() string { return "doc_paginas" }
|
|
|
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
|
|
|
func GetAllDocPaginas(limit, offset int, search string, saasID, categoriaID uint) ([]DocPagina, int64, error) {
|
|
var items []DocPagina
|
|
var total int64
|
|
db := app.Http.Database.DB.Model(&DocPagina{}).
|
|
Preload("Saas").Preload("Categoria").Preload("Roles")
|
|
if search != "" {
|
|
db = db.Where("titulo ILIKE ?", "%"+search+"%")
|
|
}
|
|
if saasID > 0 {
|
|
db = db.Where("saas_id = ?", saasID)
|
|
}
|
|
if categoriaID > 0 {
|
|
db = db.Where("categoria_id = ?", categoriaID)
|
|
}
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := db.Order("saas_id ASC, orden ASC, created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
// GetDocPaginasPublicas devuelve páginas publicadas y públicas de un SaaS, agrupables por categoría.
|
|
func GetDocPaginasPublicas(saasID uint) ([]DocPagina, error) {
|
|
var items []DocPagina
|
|
if err := app.Http.Database.DB.
|
|
Preload("Categoria").
|
|
Where("saas_id = ? AND visibilidad = 'public' AND publicado = true", saasID).
|
|
Order("orden ASC, titulo ASC").
|
|
Find(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetDocPaginasParaRoles devuelve páginas publicadas accesibles por los roles dados (incluye public + private con rol).
|
|
func GetDocPaginasParaRoles(saasID uint, rolNames []string) ([]DocPagina, error) {
|
|
var items []DocPagina
|
|
db := app.Http.Database.DB.Preload("Categoria").Preload("Roles")
|
|
|
|
// Páginas públicas del SaaS
|
|
if err := db.Where("saas_id = ? AND publicado = true AND visibilidad = 'public'", saasID).
|
|
Order("orden ASC, titulo ASC").Find(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Páginas privadas accesibles por los roles del usuario
|
|
if len(rolNames) > 0 {
|
|
var privadas []DocPagina
|
|
if err := app.Http.Database.DB.Preload("Categoria").Preload("Roles").
|
|
Joins("JOIN doc_pagina_roles dpr ON dpr.doc_pagina_id = doc_paginas.id").
|
|
Joins("JOIN roles r ON r.id = dpr.roles_id").
|
|
Where("doc_paginas.saas_id = ? AND doc_paginas.publicado = true AND doc_paginas.visibilidad = 'private' AND r.name IN ?", saasID, rolNames).
|
|
Group("doc_paginas.id").
|
|
Order("doc_paginas.orden ASC, doc_paginas.titulo ASC").
|
|
Find(&privadas).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, privadas...)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func GetDocPaginaByID(id uint) (*DocPagina, error) {
|
|
var item DocPagina
|
|
if err := app.Http.Database.DB.Preload("Saas").Preload("Categoria").Preload("Roles").First(&item, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func GetDocPaginaBySlug(saasID uint, slug string) (*DocPagina, error) {
|
|
var item DocPagina
|
|
if err := app.Http.Database.DB.Preload("Saas").Preload("Categoria").Preload("Roles").
|
|
Where("saas_id = ? AND slug = ?", saasID, slug).
|
|
First(&item).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func CreateDocPagina(p *DocPagina, rolIDs []uint) error {
|
|
db := app.Http.Database.DB
|
|
if err := db.Omit("Roles.*").Create(p).Error; err != nil {
|
|
return err
|
|
}
|
|
return syncDocPaginaRoles(p.ID, rolIDs)
|
|
}
|
|
|
|
func UpdateDocPagina(p *DocPagina, rolIDs []uint) error {
|
|
db := app.Http.Database.DB
|
|
if err := db.Model(&DocPagina{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
|
"saas_id": p.SaasID,
|
|
"categoria_id": p.CategoriaID,
|
|
"titulo": p.Titulo,
|
|
"slug": p.Slug,
|
|
"contenido": p.Contenido,
|
|
"tipo_contenido": p.TipoContenido,
|
|
"visibilidad": p.Visibilidad,
|
|
"orden": p.Orden,
|
|
"publicado": p.Publicado,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
return syncDocPaginaRoles(p.ID, rolIDs)
|
|
}
|
|
|
|
func DeleteDocPagina(id uint) error {
|
|
db := app.Http.Database.DB
|
|
// Limpiar la tabla join antes de borrar el registro
|
|
if err := db.Exec("DELETE FROM doc_pagina_roles WHERE doc_pagina_id = ?", id).Error; err != nil {
|
|
return err
|
|
}
|
|
return db.Delete(&DocPagina{}, id).Error
|
|
}
|
|
|
|
// syncDocPaginaRoles sincroniza los roles de una página usando SQL directo.
|
|
func syncDocPaginaRoles(paginaID uint, rolIDs []uint) error {
|
|
db := app.Http.Database.DB
|
|
if err := db.Exec("DELETE FROM doc_pagina_roles WHERE doc_pagina_id = ?", paginaID).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, rid := range rolIDs {
|
|
if err := db.Exec(
|
|
"INSERT INTO doc_pagina_roles (doc_pagina_id, roles_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
|
paginaID, rid,
|
|
).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|