Auditoría de /portal/dashboard. Cuatro problemas, todos en el mismo bucle: 1. El avance se veía un render atrasado. ActualizarProgresoProyecto corría DESPUÉS de cargar los proyectos: escribía el valor nuevo en la base pero los structs ya cargados seguían con el viejo, que es lo que se renderiza. El usuario veía el cálculo de la visita anterior. 2. N+1 con escrituras en un GET: dos COUNT y un UPDATE por proyecto. Con 10 proyectos, 30 consultas y 10 escrituras por cada carga del dashboard — incluyendo las de cualquier bot que pase. Ahora es UNA consulta agrupada y ninguna escritura; los caminos que tocan una fase ya mantienen la columna al día, así que recalcular en el GET no aportaba nada. 3. Orden aleatorio de los grupos: se recorría un map de Go, así que un partner veía sus clientes en distinto orden en cada recarga. 4. isPartner se decidía con u.Rol mientras el alcance se decidía con Role.EsPortalPartner. Desincronizados, un usuario veía proyectos de varios clientes sin agrupar, o la vista agrupada vacía. Ahora hay una sola definición (PortalUser.EsPartner) con test de los dos sentidos. Además, el error de carga se descartaba con `_` y el usuario terminaba viendo "no tenés proyectos", indistinguible de una caída de la base. Sin hallazgos de seguridad: el chequeo de acceso por cliente está en todas las rutas, la sesión revalida Activo en cada request, y las plantillas no usan x-html ni template.HTML, así que Go escapa todo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
284 lines
11 KiB
Go
284 lines
11 KiB
Go
package models
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ─── PortalUser ───────────────────────────────────────────────────────────────
|
|
|
|
// PortalUser representa un usuario del portal de clientes (separado del admin).
|
|
// - Rol "cliente": accede a los proyectos de su ClienteID.
|
|
// - Rol "partner": accede a los proyectos de todos sus ClienteIDs via PortalAccesos.
|
|
type PortalUser struct {
|
|
gorm.Model
|
|
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
|
|
Email string `json:"email" gorm:"column:email;uniqueIndex;not null"`
|
|
Password string `json:"-" gorm:"column:password;type:text"`
|
|
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"` // nil si es partner
|
|
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
|
Rol string `json:"rol" gorm:"column:rol;default:'cliente'"` // cliente|partner
|
|
RoleID *uint `json:"role_id" gorm:"column:role_id;index"`
|
|
Role *Roles `json:"role" gorm:"foreignKey:RoleID"`
|
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
|
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
|
TelegramChatID string `json:"telegram_chat_id" gorm:"column:telegram_chat_id"`
|
|
Telefono string `json:"telefono" gorm:"column:telefono"`
|
|
Indicativo string `json:"indicativo" gorm:"column:indicativo"` // ej: +57, +1
|
|
Pais string `json:"pais" gorm:"column:pais"`
|
|
Ciudad string `json:"ciudad" gorm:"column:ciudad"`
|
|
Documento string `json:"documento" gorm:"column:documento"` // RUT, NIT, CC, etc.
|
|
Empresa string `json:"empresa" gorm:"column:empresa"` // empresa que representa
|
|
SitioWeb string `json:"sitio_web" gorm:"column:sitio_web"` // sitio web del partner/cliente
|
|
DocumentoRutFile string `json:"documento_rut_file" gorm:"column:documento_rut_file"` // ruta del archivo RUT
|
|
DocumentoRutNombre string `json:"documento_rut_nombre" gorm:"column:documento_rut_nombre"` // nombre original
|
|
PortalAccesos []PortalAcceso `json:"portal_accesos" gorm:"foreignKey:PortalUserID"`
|
|
}
|
|
|
|
func (PortalUser) TableName() string { return "portal_users" }
|
|
|
|
// ─── PortalAcceso (partners → multiple clients) ───────────────────────────────
|
|
|
|
type PortalAcceso struct {
|
|
gorm.Model
|
|
PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
|
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
|
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
|
}
|
|
|
|
func (PortalAcceso) TableName() string { return "portal_accesos" }
|
|
|
|
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
|
|
|
func GetAllPortalUsers(limit, offset int) ([]PortalUser, int64, error) {
|
|
var items []PortalUser
|
|
var total int64
|
|
db := app.Http.Database.DB.Model(&PortalUser{}).Preload("Cliente").Preload("PortalAccesos.Cliente").Preload("Role")
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := db.Order("created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func GetPortalUserByID(id uint) (*PortalUser, error) {
|
|
var item PortalUser
|
|
err := app.Http.Database.DB.Preload("Cliente").Preload("PortalAccesos.Cliente").Preload("Role").First(&item, id).Error
|
|
return &item, err
|
|
}
|
|
|
|
func GetPortalUserByEmail(email string) (*PortalUser, error) {
|
|
var item PortalUser
|
|
err := app.Http.Database.DB.Where("email = ?", email).First(&item).Error
|
|
return &item, err
|
|
}
|
|
|
|
func CreatePortalUser(u *PortalUser) error {
|
|
return app.Http.Database.DB.Create(u).Error
|
|
}
|
|
|
|
func UpdatePortalUser(u *PortalUser) error {
|
|
return app.Http.Database.DB.Model(&PortalUser{}).Where("id = ?", u.ID).Updates(map[string]interface{}{
|
|
"nombre": u.Nombre,
|
|
"email": u.Email,
|
|
"cliente_id": u.ClienteID,
|
|
"rol": u.Rol,
|
|
"role_id": u.RoleID,
|
|
"activo": u.Activo,
|
|
"notas": u.Notas,
|
|
"telegram_chat_id": u.TelegramChatID,
|
|
"telefono": u.Telefono,
|
|
"indicativo": u.Indicativo,
|
|
"pais": u.Pais,
|
|
"ciudad": u.Ciudad,
|
|
"documento": u.Documento,
|
|
"empresa": u.Empresa,
|
|
"sitio_web": u.SitioWeb,
|
|
}).Error
|
|
}
|
|
|
|
func UpdatePortalUserPassword(id uint, hashedPassword string) error {
|
|
return app.Http.Database.DB.Model(&PortalUser{}).Where("id = ?", id).Update("password", hashedPassword).Error
|
|
}
|
|
|
|
func UpdatePortalUserTelegramChatID(id uint, chatID string) error {
|
|
return app.Http.Database.DB.Model(&PortalUser{}).Where("id = ?", id).Update("telegram_chat_id", chatID).Error
|
|
}
|
|
|
|
func UpdatePortalUserRutFile(id uint, path, nombre string) error {
|
|
return app.Http.Database.DB.Model(&PortalUser{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"documento_rut_file": path,
|
|
"documento_rut_nombre": nombre,
|
|
}).Error
|
|
}
|
|
|
|
func DeletePortalUser(id uint) error {
|
|
// Eliminar también sus accesos de partner
|
|
if err := app.Http.Database.DB.Where("portal_user_id = ?", id).Delete(&PortalAcceso{}).Error; err != nil {
|
|
return err
|
|
}
|
|
return app.Http.Database.DB.Delete(&PortalUser{}, id).Error
|
|
}
|
|
|
|
// GetPortalUsersByClienteID devuelve todos los portal_users activos asociados a un cliente,
|
|
// ya sea directamente (rol cliente) o mediante PortalAcceso (rol partner).
|
|
func GetPortalUsersByClienteID(clienteID uint) ([]PortalUser, error) {
|
|
var result []PortalUser
|
|
db := app.Http.Database.DB
|
|
|
|
// Usuarios directos del cliente
|
|
var directos []PortalUser
|
|
if err := db.Where("cliente_id = ? AND activo = true", clienteID).Find(&directos).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, directos...)
|
|
|
|
// Usuarios partner con acceso al cliente via PortalAcceso
|
|
var accesos []PortalAcceso
|
|
if err := db.Where("cliente_id = ?", clienteID).Find(&accesos).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
seen := make(map[uint]bool)
|
|
for _, d := range directos {
|
|
seen[d.ID] = true
|
|
}
|
|
for _, a := range accesos {
|
|
if seen[a.PortalUserID] {
|
|
continue
|
|
}
|
|
var u PortalUser
|
|
if err := db.Where("id = ? AND activo = true", a.PortalUserID).First(&u).Error; err == nil {
|
|
result = append(result, u)
|
|
seen[u.ID] = true
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func AddPortalAcceso(portalUserID, clienteID uint) error {
|
|
// Evitar duplicados
|
|
var existing PortalAcceso
|
|
err := app.Http.Database.DB.Where("portal_user_id = ? AND cliente_id = ?", portalUserID, clienteID).First(&existing).Error
|
|
if err == nil {
|
|
return nil // ya existe
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
return app.Http.Database.DB.Create(&PortalAcceso{PortalUserID: portalUserID, ClienteID: clienteID}).Error
|
|
}
|
|
|
|
func RemovePortalAcceso(portalUserID, clienteID uint) error {
|
|
return app.Http.Database.DB.Where("portal_user_id = ? AND cliente_id = ?", portalUserID, clienteID).
|
|
Delete(&PortalAcceso{}).Error
|
|
}
|
|
|
|
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
|
|
// EsPartner es la ÚNICA definición de "este usuario es partner". Existe
|
|
// porque el rol se guarda en dos lados (la columna Rol y el flag
|
|
// EsPortalPartner del Role) y consultarlos por separado se contradice: el
|
|
// dashboard decidía el layout con u.Rol y los proyectos con Role, así que un
|
|
// usuario con los dos desincronizados veía proyectos de varios clientes sin
|
|
// agrupar, o la vista agrupada vacía.
|
|
func (u *PortalUser) EsPartner() bool {
|
|
if u == nil {
|
|
return false
|
|
}
|
|
if u.Role != nil {
|
|
return u.Role.EsPortalPartner
|
|
}
|
|
return u.Rol == "partner"
|
|
}
|
|
|
|
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
|
|
if u == nil {
|
|
return []uint{}
|
|
}
|
|
if u.EsPartner() {
|
|
ids := make([]uint, 0, len(u.PortalAccesos))
|
|
for _, a := range u.PortalAccesos {
|
|
ids = append(ids, a.ClienteID)
|
|
}
|
|
return ids
|
|
}
|
|
if u.ClienteID != nil {
|
|
return []uint{*u.ClienteID}
|
|
}
|
|
return []uint{}
|
|
}
|
|
|
|
// ─── PortalPasswordResetToken ────────────────────────────────────────────────
|
|
|
|
type PortalPasswordResetToken struct {
|
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
|
PortalUserID uint `gorm:"column:portal_user_id;index;not null"`
|
|
Token string `gorm:"column:token;uniqueIndex;not null"`
|
|
ExpiresAt time.Time `gorm:"column:expires_at;not null"`
|
|
Used bool `gorm:"column:used;default:false"`
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
func (PortalPasswordResetToken) TableName() string { return "portal_password_reset_tokens" }
|
|
|
|
// CreatePortalResetToken genera un token aleatorio seguro de 32 bytes, lo persiste y lo retorna.
|
|
func CreatePortalResetToken(portalUserID uint) (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
token := hex.EncodeToString(b)
|
|
record := &PortalPasswordResetToken{
|
|
PortalUserID: portalUserID,
|
|
Token: token,
|
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
if err := app.Http.Database.DB.Create(record).Error; err != nil {
|
|
return "", err
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// GetValidPortalResetToken busca el token, verifica que no esté usado ni vencido.
|
|
func GetValidPortalResetToken(token string) (*PortalPasswordResetToken, error) {
|
|
var record PortalPasswordResetToken
|
|
err := app.Http.Database.DB.Where("token = ?", token).First(&record).Error
|
|
if err != nil {
|
|
return nil, errors.New("token inválido")
|
|
}
|
|
if record.Used {
|
|
return nil, errors.New("este enlace ya fue utilizado")
|
|
}
|
|
if time.Now().After(record.ExpiresAt) {
|
|
return nil, errors.New("el enlace ha expirado, solicita uno nuevo")
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
// MarkPortalResetTokenUsed marca el token como utilizado.
|
|
func MarkPortalResetTokenUsed(id uint) error {
|
|
return app.Http.Database.DB.Model(&PortalPasswordResetToken{}).Where("id = ?", id).Update("used", true).Error
|
|
}
|
|
|
|
// CheckPortalLogin valida credenciales y retorna el portal user.
|
|
func CheckPortalLogin(email, password string) (*PortalUser, error) {
|
|
u, err := GetPortalUserByEmail(email)
|
|
if err != nil {
|
|
return nil, errors.New("Usuario no encontrado o inactivo")
|
|
}
|
|
if !u.Activo {
|
|
return nil, errors.New("Usuario inactivo")
|
|
}
|
|
match, err := app.Http.Hash.Match(password, u.Password)
|
|
if err != nil || !match {
|
|
return nil, errors.New("Correo o contraseña incorrectos")
|
|
}
|
|
return u, nil
|
|
}
|