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>
This commit is contained in:
Lizandro Guarnizo
2026-08-14 22:26:44 -05:00
co-authored by Claude Sonnet 5
parent 62c644b815
commit cfa91a5c09
5 changed files with 175 additions and 20 deletions
+26 -11
View File
@@ -181,12 +181,27 @@ func RemovePortalAcceso(portalUserID, clienteID uint) error {
}
// GetClienteIDsForPortalUser devuelve todos los clienteIDs accesibles para un portal user.
func GetClienteIDsForPortalUser(u *PortalUser) []uint {
isPartner := u.Rol == "partner"
if u.Role != nil {
isPartner = u.Role.EsPortalPartner
// 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 isPartner {
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)
@@ -202,12 +217,12 @@ func GetClienteIDsForPortalUser(u *PortalUser) []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
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" }