diff --git a/pkg/models/portal_user.go b/pkg/models/portal_user.go index 90f3892..0789ccb 100644 --- a/pkg/models/portal_user.go +++ b/pkg/models/portal_user.go @@ -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" } diff --git a/pkg/models/portal_user_partner_test.go b/pkg/models/portal_user_partner_test.go new file mode 100644 index 0000000..ab75a7f --- /dev/null +++ b/pkg/models/portal_user_partner_test.go @@ -0,0 +1,62 @@ +package models + +import "testing" + +// El rol vive en dos lados (la columna Rol y el flag EsPortalPartner del +// Role). Consultarlos por separado se contradecía: el dashboard elegía el +// layout con uno y los proyectos con el otro. EsPartner es ahora la única +// definición, y estos casos fijan cuál gana. +func TestEsPartner(t *testing.T) { + casos := []struct { + nombre string + user *PortalUser + esperado bool + }{ + {"nil no es partner", nil, false}, + {"sin Role, rol partner", &PortalUser{Rol: "partner"}, true}, + {"sin Role, rol cliente", &PortalUser{Rol: "cliente"}, false}, + {"con Role, el flag manda aunque el rol diga cliente", + &PortalUser{Rol: "cliente", Role: &Roles{EsPortalPartner: true}}, true}, + {"con Role, el flag manda aunque el rol diga partner", + &PortalUser{Rol: "partner", Role: &Roles{EsPortalPartner: false}}, false}, + } + for _, cas := range casos { + t.Run(cas.nombre, func(t *testing.T) { + if got := cas.user.EsPartner(); got != cas.esperado { + t.Errorf("EsPartner() = %v, esperaba %v", got, cas.esperado) + } + }) + } +} + +// El alcance del portal se deriva de EsPartner: un desajuste acá es que un +// usuario vea proyectos de un cliente que no le corresponde. +func TestGetClienteIDsForPortalUser(t *testing.T) { + cid := uint(7) + casos := []struct { + nombre string + user *PortalUser + esperado []uint + }{ + {"nil no ve nada", nil, []uint{}}, + {"cliente ve el suyo", &PortalUser{Rol: "cliente", ClienteID: &cid}, []uint{7}}, + {"cliente sin ClienteID no ve nada", &PortalUser{Rol: "cliente"}, []uint{}}, + {"partner ve los de sus accesos", + &PortalUser{Rol: "partner", PortalAccesos: []PortalAcceso{{ClienteID: 3}, {ClienteID: 9}}}, + []uint{3, 9}}, + {"partner sin accesos no ve nada", &PortalUser{Rol: "partner"}, []uint{}}, + } + for _, cas := range casos { + t.Run(cas.nombre, func(t *testing.T) { + got := GetClienteIDsForPortalUser(cas.user) + if len(got) != len(cas.esperado) { + t.Fatalf("= %v, esperaba %v", got, cas.esperado) + } + for i := range got { + if got[i] != cas.esperado[i] { + t.Errorf("= %v, esperaba %v", got, cas.esperado) + } + } + }) + } +} diff --git a/pkg/models/proyecto.go b/pkg/models/proyecto.go index 1fda52b..eae1ba6 100644 --- a/pkg/models/proyecto.go +++ b/pkg/models/proyecto.go @@ -327,3 +327,53 @@ func GetProyectoDocumentoByID(id uint) (*ProyectoDocumento, error) { func DeleteProyectoDocumento(id uint) error { return app.Http.Database.DB.Delete(&ProyectoDocumento{}, id).Error } + +// ProgresoPorProyecto calcula el avance de varios proyectos en UNA consulta +// agrupada. +// +// El dashboard hacía esto con ActualizarProgresoProyecto en un bucle: dos +// COUNT y un UPDATE por proyecto (3N consultas, N de ellas escrituras en un +// GET). Y no servía para lo que se estaba renderizando: los proyectos ya +// estaban cargados en memoria, así que el UPDATE iba a la base pero la +// pantalla seguía mostrando el valor anterior — el avance se veía siempre un +// render atrasado. +// +// Las escrituras no hacen falta: todos los caminos que tocan una fase ya +// llaman a ActualizarProgresoProyecto (ver proyecto_controller y +// proyecto_service). Acá solo se lee. +func ProgresoPorProyecto(proyectoIDs []uint) map[uint]int { + out := make(map[uint]int, len(proyectoIDs)) + if len(proyectoIDs) == 0 { + return out + } + var filas []struct { + ProyectoID uint + Total int64 + Completados int64 + } + app.Http.Database.DB.Model(&ProyectoFase{}). + Select("proyecto_id, COUNT(*) AS total, SUM(CASE WHEN estado = 'completado' THEN 1 ELSE 0 END) AS completados"). + Where("proyecto_id IN ? AND deleted_at IS NULL", proyectoIDs). + Group("proyecto_id").Scan(&filas) + + for _, f := range filas { + if f.Total > 0 { + out[f.ProyectoID] = int((f.Completados * 100) / f.Total) + } + } + return out +} + +// AplicarProgreso pone el avance recién calculado en los structs que se van a +// renderizar. Un proyecto sin fases queda en 0, que es lo correcto: no hay +// nada planificado todavía. +func AplicarProgreso(proyectos []Proyecto) { + ids := make([]uint, len(proyectos)) + for i, p := range proyectos { + ids[i] = p.ID + } + progresos := ProgresoPorProyecto(ids) + for i := range proyectos { + proyectos[i].Progreso = progresos[proyectos[i].ID] + } +} diff --git a/resources/views/portal/dashboard.html b/resources/views/portal/dashboard.html index b9169b6..931640b 100644 --- a/resources/views/portal/dashboard.html +++ b/resources/views/portal/dashboard.html @@ -15,6 +15,14 @@
+ + {{ if .error }} +