Files
soft_usite/pkg/models/proyecto.go
T
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

380 lines
16 KiB
Go

package models
import (
"encoding/json"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// ─── Proyecto ─────────────────────────────────────────────────────────────────
type Proyecto struct {
gorm.Model
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"`
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
Nombre string `json:"nombre" gorm:"column:nombre;not null"`
Slug string `json:"slug" gorm:"column:slug;uniqueIndex;not null"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Color string `json:"color" gorm:"column:color;default:'#8eb02f'"`
Estado string `json:"estado" gorm:"column:estado;default:'activo'"` // activo|pausado|completado
Progreso int `json:"progreso" gorm:"column:progreso;default:0"` // 0-100
}
func (Proyecto) TableName() string { return "proyectos" }
// ─── ProyectoFase (roadmap steps) ─────────────────────────────────────────────
type ProyectoFase struct {
gorm.Model
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
Nombre string `json:"nombre" gorm:"column:nombre"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente|en_progreso|completado
Orden int `json:"orden" gorm:"column:orden;default:0"`
FechaEstimada *time.Time `json:"fecha_estimada" gorm:"column:fecha_estimada"`
FechaCompletado *time.Time `json:"fecha_completado" gorm:"column:fecha_completado"`
EntregablesJSON string `json:"entregables_json" gorm:"column:entregables_json;type:text"`
Entregables []string `json:"entregables" gorm:"-"`
}
func (ProyectoFase) TableName() string { return "proyecto_fases" }
func (f *ProyectoFase) ParseEntregables() {
if f.EntregablesJSON != "" {
_ = json.Unmarshal([]byte(f.EntregablesJSON), &f.Entregables)
}
if f.Entregables == nil {
f.Entregables = []string{}
}
}
func (f *ProyectoFase) SerializeEntregables() {
b, _ := json.Marshal(f.Entregables)
f.EntregablesJSON = string(b)
}
// ─── ProyectoAvance (news feed / updates) ─────────────────────────────────────
type ProyectoAvance struct {
gorm.Model
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
Titulo string `json:"titulo" gorm:"column:titulo"`
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
Tipo string `json:"tipo" gorm:"column:tipo;default:'update'"` // update|milestone|release
Visible bool `json:"visible" gorm:"column:visible;default:true"`
}
func (ProyectoAvance) TableName() string { return "proyecto_avances" }
// ─── ProyectoEntregable (downloadable files) ──────────────────────────────────
type ProyectoEntregable struct {
gorm.Model
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
Nombre string `json:"nombre" gorm:"column:nombre"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Archivo string `json:"archivo" gorm:"column:archivo"`
OriginalName string `json:"original_name" gorm:"column:original_name"`
Version string `json:"version" gorm:"column:version"`
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
Visible bool `json:"visible" gorm:"column:visible;default:true"`
}
func (ProyectoEntregable) TableName() string { return "proyecto_entregables" }
// ─── Fase template (los 11 pasos de u-site) ───────────────────────────────────
var FaseTemplateDefault = []struct {
Nombre string
Descripcion string
Entregables []string
}{
{"Captación y calificación", "Marketing de contenidos, ads y referidos. Primer contacto y diagnóstico rápido. Identificación del tipo de cliente y evaluación de presupuesto.", []string{"Lead calificado", "Brief inicial del problema"}},
{"Descubrimiento y diagnóstico", "Reunión profunda sobre procesos y herramientas. Mapeo del flujo de trabajo actual. Identificación de cuellos de botella y priorización.", []string{"Documento de diagnóstico", "Lista de oportunidades"}},
{"Definición de solución y alcance", "Diseño de arquitectura modular. Definición de módulos, componentes e integraciones. Priorización por fases (MVP → evolución).", []string{"Propuesta técnica", "Alcance funcional", "Roadmap del proyecto"}},
{"Propuesta comercial y cierre", "Definición de modelo de cobro, presentación y ajustes según feedback. Negociación y firma.", []string{"Cotización formal", "Contrato / acuerdo", "Cronograma inicial"}},
{"Diseño funcional y técnico", "Wireframes o prototipos, definición de APIs e integraciones, diseño de base de datos.", []string{"Especificación funcional", "Arquitectura definida", "Backlog de desarrollo"}},
{"Desarrollo e implementación", "Desarrollo por módulos o iteraciones. Integraciones con herramientas externas. Reuniones de seguimiento.", []string{"Módulos funcionales", "Versiones iterativas"}},
{"Pruebas y validación", "Pruebas técnicas y con usuario (UAT). Ajustes, correcciones y validación de flujos completos.", []string{"Sistema validado", "Lista de ajustes finales"}},
{"Despliegue y puesta en producción", "Configuración de infraestructura, migración de datos, activación de automatizaciones y monitoreo inicial.", []string{"Sistema en producción", "Documentación básica de uso"}},
{"Capacitación y adopción", "Entrenamiento al equipo, guías rápidas o videos, resolución de dudas y ajustes por uso real.", []string{"Usuarios activos", "Procesos adoptados"}},
{"Soporte y mejora continua", "Soporte técnico, nuevas automatizaciones o módulos, optimización y evolución del sistema.", []string{"Roadmap evolutivo", "Mejoras continuas"}},
{"Cierre formal del proyecto", "Validación final, entrega de documentación, evaluación de resultados (antes vs después) y testimonio.", []string{"Acta de cierre", "Caso de estudio (opcional)", "Oportunidades de upsell"}},
}
// ─── CRUD Proyecto ─────────────────────────────────────────────────────────────
func GetAllProyectos(limit, offset int, search string) ([]Proyecto, int64, error) {
var items []Proyecto
var total int64
db := app.Http.Database.DB.Model(&Proyecto{}).Preload("Cliente")
if search != "" {
db = db.Where("proyectos.nombre ILIKE ? OR proyectos.descripcion ILIKE ?", "%"+search+"%", "%"+search+"%")
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("proyectos.created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func GetProyectosByClienteIDs(clienteIDs []uint) ([]Proyecto, error) {
if len(clienteIDs) == 0 {
return []Proyecto{}, nil
}
var items []Proyecto
err := app.Http.Database.DB.Preload("Cliente").Where("cliente_id IN ?", clienteIDs).Order("created_at DESC").Find(&items).Error
return items, err
}
func GetProyectoByID(id uint) (*Proyecto, error) {
var item Proyecto
err := app.Http.Database.DB.Preload("Cliente").First(&item, id).Error
return &item, err
}
func GetProyectoBySlug(slug string) (*Proyecto, error) {
var item Proyecto
err := app.Http.Database.DB.Preload("Cliente").Where("slug = ?", slug).First(&item).Error
return &item, err
}
func CreateProyecto(p *Proyecto) error {
return app.Http.Database.DB.Create(p).Error
}
func CalcularProgresoDesdeFases(proyectoID uint) int {
var total, completados int64
app.Http.Database.DB.Model(&ProyectoFase{}).Where("proyecto_id = ?", proyectoID).Count(&total)
if total == 0 {
return 0
}
app.Http.Database.DB.Model(&ProyectoFase{}).Where("proyecto_id = ? AND estado = ?", proyectoID, "completado").Count(&completados)
return int((completados * 100) / total)
}
func ActualizarProgresoProyecto(proyectoID uint) error {
nuevo := CalcularProgresoDesdeFases(proyectoID)
return app.Http.Database.DB.Model(&Proyecto{}).Where("id = ?", proyectoID).Update("progreso", nuevo).Error
}
func UpdateProyecto(p *Proyecto) error {
return app.Http.Database.DB.Model(&Proyecto{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
"nombre": p.Nombre,
"slug": p.Slug,
"descripcion": p.Descripcion,
"color": p.Color,
"estado": p.Estado,
"progreso": p.Progreso,
"cliente_id": p.ClienteID,
}).Error
}
func DeleteProyecto(id uint) error {
return app.Http.Database.DB.Delete(&Proyecto{}, id).Error
}
// ─── CRUD ProyectoFase ─────────────────────────────────────────────────────────
func GetFasesByProyecto(proyectoID uint) ([]ProyectoFase, error) {
var items []ProyectoFase
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).Order("orden ASC, id ASC").Find(&items).Error
for i := range items {
items[i].ParseEntregables()
}
return items, err
}
func CreateProyectoFase(f *ProyectoFase) error {
f.SerializeEntregables()
return app.Http.Database.DB.Create(f).Error
}
func UpdateProyectoFase(f *ProyectoFase) error {
f.SerializeEntregables()
return app.Http.Database.DB.Model(&ProyectoFase{}).Where("id = ?", f.ID).Updates(map[string]interface{}{
"nombre": f.Nombre,
"descripcion": f.Descripcion,
"estado": f.Estado,
"orden": f.Orden,
"fecha_estimada": f.FechaEstimada,
"fecha_completado": f.FechaCompletado,
"entregables_json": f.EntregablesJSON,
}).Error
}
func DeleteProyectoFase(id uint) error {
return app.Http.Database.DB.Delete(&ProyectoFase{}, id).Error
}
func ApplyFaseTemplate(proyectoID uint) error {
if err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).Delete(&ProyectoFase{}).Error; err != nil {
return err
}
for i, tpl := range FaseTemplateDefault {
ents, _ := json.Marshal(tpl.Entregables)
fase := &ProyectoFase{
ProyectoID: proyectoID,
Nombre: tpl.Nombre,
Descripcion: tpl.Descripcion,
Orden: i + 1,
Estado: "pendiente",
EntregablesJSON: string(ents),
}
if err := app.Http.Database.DB.Create(fase).Error; err != nil {
return err
}
}
return nil
}
// ─── CRUD ProyectoAvance ───────────────────────────────────────────────────────
func GetAvancesByProyecto(proyectoID uint, soloVisibles bool) ([]ProyectoAvance, error) {
var items []ProyectoAvance
db := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID)
if soloVisibles {
db = db.Where("visible = true")
}
err := db.Order("created_at DESC").Find(&items).Error
return items, err
}
func CreateProyectoAvance(a *ProyectoAvance) error {
return app.Http.Database.DB.Create(a).Error
}
func UpdateProyectoAvance(a *ProyectoAvance) error {
return app.Http.Database.DB.Model(&ProyectoAvance{}).Where("id = ?", a.ID).Updates(map[string]interface{}{
"titulo": a.Titulo,
"contenido": a.Contenido,
"tipo": a.Tipo,
"visible": a.Visible,
}).Error
}
func DeleteProyectoAvance(id uint) error {
return app.Http.Database.DB.Delete(&ProyectoAvance{}, id).Error
}
// ─── CRUD ProyectoEntregable ───────────────────────────────────────────────────
func GetEntregablesByProyecto(proyectoID uint, soloVisibles bool) ([]ProyectoEntregable, error) {
var items []ProyectoEntregable
db := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID)
if soloVisibles {
db = db.Where("visible = true")
}
err := db.Order("created_at DESC").Find(&items).Error
return items, err
}
func CreateProyectoEntregable(e *ProyectoEntregable) error {
return app.Http.Database.DB.Create(e).Error
}
func GetProyectoEntregableByID(id uint) (*ProyectoEntregable, error) {
var item ProyectoEntregable
err := app.Http.Database.DB.First(&item, id).Error
return &item, err
}
func DeleteProyectoEntregable(id uint) error {
return app.Http.Database.DB.Delete(&ProyectoEntregable{}, id).Error
}
func UpdateEntregableVisibilidad(id uint, visible bool) error {
return app.Http.Database.DB.Model(&ProyectoEntregable{}).Where("id = ?", id).Update("visible", visible).Error
}
// ─── ProyectoDocumento (contratos y órdenes de servicio) ──────────────────────
type ProyectoDocumento struct {
gorm.Model
ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
Tipo string `json:"tipo" gorm:"column:tipo;default:'contrato'"` // contrato|orden_servicio|otro
Nombre string `json:"nombre" gorm:"column:nombre"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Archivo string `json:"archivo" gorm:"column:archivo"`
OriginalName string `json:"original_name" gorm:"column:original_name"`
TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"`
Tamanio int64 `json:"tamanio" gorm:"column:tamanio"`
}
func (ProyectoDocumento) TableName() string { return "proyecto_documentos" }
func GetDocumentosByProyecto(proyectoID uint) ([]ProyectoDocumento, error) {
var items []ProyectoDocumento
err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID).Order("created_at DESC").Find(&items).Error
return items, err
}
func CreateProyectoDocumento(d *ProyectoDocumento) error {
return app.Http.Database.DB.Create(d).Error
}
func GetProyectoDocumentoByID(id uint) (*ProyectoDocumento, error) {
var item ProyectoDocumento
err := app.Http.Database.DB.First(&item, id).Error
return &item, err
}
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]
}
}