Files
Lizandro GuarnizoandClaude Sonnet 5 cfa91a5c09 fix(portal): el avance del dashboard mostraba siempre el valor anterior
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>
2026-08-14 22:26:44 -05:00

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
}