diff --git a/migrations/migrate.go b/migrations/migrate.go index c91f1a5..5126721 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -72,6 +72,16 @@ func Migrate() { // Telegram &models.TelegramConfig{}, &models.TelegramLog{}, + // Portal de Clientes + &models.PortalUser{}, + &models.PortalAcceso{}, + &models.Proyecto{}, + &models.ProyectoFase{}, + &models.ProyectoAvance{}, + &models.ProyectoEntregable{}, + &models.ProyectoTicket{}, + &models.TicketMensaje{}, + &models.Factura{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } diff --git a/pkg/auth/portal.go b/pkg/auth/portal.go new file mode 100644 index 0000000..906d196 --- /dev/null +++ b/pkg/auth/portal.go @@ -0,0 +1,53 @@ +package auth + +import ( + "errors" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +const portalSessionKey = "portal_user_id" + +// PortalUser obtiene el usuario portal de la sesión actual. +func PortalUser(c *fiber.Ctx) (*models.PortalUser, error) { + store := app.Http.Session.Get(c) + raw := store.Get(portalSessionKey) + if raw == nil { + return nil, errors.New("no portal session") + } + id, ok := raw.(uint) + if !ok { + return nil, errors.New("invalid portal session") + } + u, err := models.GetPortalUserByID(id) + if err != nil { + return nil, err + } + if !u.Activo { + return nil, errors.New("portal user inactive") + } + // Precargar accesos del portal user para que GetClienteIDsForPortalUser funcione + return u, nil +} + +// SetPortalSession guarda el portal_user_id en la sesión. +func SetPortalSession(c *fiber.Ctx, userID uint) error { + store := app.Http.Session.Get(c) + store.Set(portalSessionKey, userID) + return store.Save() +} + +// DestroyPortalSession elimina la sesión del portal. +func DestroyPortalSession(c *fiber.Ctx) error { + store := app.Http.Session.Get(c) + store.Delete(portalSessionKey) + return store.Save() +} + +// IsPortalLoggedIn verifica si hay sesión de portal activa. +func IsPortalLoggedIn(c *fiber.Ctx) bool { + store := app.Http.Session.Get(c) + return store.Get(portalSessionKey) != nil +} diff --git a/pkg/models/factura.go b/pkg/models/factura.go new file mode 100644 index 0000000..87b8d05 --- /dev/null +++ b/pkg/models/factura.go @@ -0,0 +1,98 @@ +package models + +import ( + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// ─── Factura ────────────────────────────────────────────────────────────────── + +type Factura struct { + gorm.Model + ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"` + Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"` + ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"` + Proyecto *Proyecto `json:"proyecto" gorm:"foreignKey:ProyectoID"` + Numero string `json:"numero" gorm:"column:numero"` + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + Monto float64 `json:"monto" gorm:"column:monto"` + Moneda string `json:"moneda" gorm:"column:moneda;default:'COP'"` + Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente|pagada|vencida|cancelada + FechaEmision time.Time `json:"fecha_emision" gorm:"column:fecha_emision"` + FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"` + Archivo string `json:"archivo" gorm:"column:archivo"` + OriginalName string `json:"original_name" gorm:"column:original_name"` + Visible bool `json:"visible" gorm:"column:visible;default:true"` + Notas string `json:"notas" gorm:"column:notas;type:text"` +} + +func (Factura) TableName() string { return "facturas" } + +// ─── CRUD ───────────────────────────────────────────────────────────────────── + +func GetAllFacturas(limit, offset int, search string) ([]Factura, int64, error) { + var items []Factura + var total int64 + db := app.Http.Database.DB.Model(&Factura{}).Preload("Cliente").Preload("Proyecto") + if search != "" { + db = db.Joins("JOIN clientes ON clientes.id = facturas.cliente_id"). + Where("facturas.numero ILIKE ? OR clientes.nombre ILIKE ? OR clientes.empresa ILIKE ?", + "%"+search+"%", "%"+search+"%", "%"+search+"%") + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("facturas.fecha_emision DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func GetFacturasByCliente(clienteID uint, soloVisibles bool) ([]Factura, error) { + var items []Factura + db := app.Http.Database.DB.Where("cliente_id = ?", clienteID) + if soloVisibles { + db = db.Where("visible = true") + } + err := db.Preload("Proyecto").Order("fecha_emision DESC").Find(&items).Error + return items, err +} + +func GetFacturaByID(id uint) (*Factura, error) { + var item Factura + err := app.Http.Database.DB.Preload("Cliente").Preload("Proyecto").First(&item, id).Error + return &item, err +} + +func CreateFactura(f *Factura) error { + return app.Http.Database.DB.Create(f).Error +} + +func UpdateFactura(f *Factura) error { + return app.Http.Database.DB.Model(&Factura{}).Where("id = ?", f.ID).Updates(map[string]interface{}{ + "cliente_id": f.ClienteID, + "proyecto_id": f.ProyectoID, + "numero": f.Numero, + "descripcion": f.Descripcion, + "monto": f.Monto, + "moneda": f.Moneda, + "estado": f.Estado, + "fecha_emision": f.FechaEmision, + "fecha_vencimiento": f.FechaVencimiento, + "visible": f.Visible, + "notas": f.Notas, + }).Error +} + +func DeleteFactura(id uint) error { + return app.Http.Database.DB.Delete(&Factura{}, id).Error +} + +func UpdateFacturaArchivo(id uint, archivo, originalName string) error { + return app.Http.Database.DB.Model(&Factura{}).Where("id = ?", id).Updates(map[string]interface{}{ + "archivo": archivo, + "original_name": originalName, + }).Error +} diff --git a/pkg/models/portal_user.go b/pkg/models/portal_user.go new file mode 100644 index 0000000..f9978a9 --- /dev/null +++ b/pkg/models/portal_user.go @@ -0,0 +1,142 @@ +package models + +import ( + "errors" + + "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 + Activo bool `json:"activo" gorm:"column:activo;default:true"` + Notas string `json:"notas" gorm:"column:notas;type:text"` + 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") + 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").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, + "activo": u.Activo, + "notas": u.Notas, + }).Error +} + +func UpdatePortalUserPassword(id uint, hashedPassword string) error { + return app.Http.Database.DB.Model(&PortalUser{}).Where("id = ?", id).Update("password", hashedPassword).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 +} + +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. +func GetClienteIDsForPortalUser(u *PortalUser) []uint { + if u.Rol == "partner" { + 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{} +} + +// 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 +} diff --git a/pkg/models/proyecto.go b/pkg/models/proyecto.go new file mode 100644 index 0000000..2e57fca --- /dev/null +++ b/pkg/models/proyecto.go @@ -0,0 +1,275 @@ +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) { + var items []Proyecto + err := app.Http.Database.DB.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 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 +} diff --git a/pkg/models/proyecto_ticket.go b/pkg/models/proyecto_ticket.go new file mode 100644 index 0000000..45bdbd2 --- /dev/null +++ b/pkg/models/proyecto_ticket.go @@ -0,0 +1,69 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// ─── ProyectoTicket ─────────────────────────────────────────────────────────── + +type ProyectoTicket struct { + gorm.Model + ProyectoID uint `json:"proyecto_id" gorm:"column:proyecto_id;index"` + PortalUserID uint `json:"portal_user_id" gorm:"column:portal_user_id;index"` + AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"` + Titulo string `json:"titulo" gorm:"column:titulo"` + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado + Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente + Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"` +} + +func (ProyectoTicket) TableName() string { return "proyecto_tickets" } + +// ─── TicketMensaje ──────────────────────────────────────────────────────────── + +type TicketMensaje struct { + gorm.Model + TicketID uint `json:"ticket_id" gorm:"column:ticket_id;index"` + Contenido string `json:"contenido" gorm:"column:contenido;type:text"` + EsAdmin bool `json:"es_admin" gorm:"column:es_admin;default:false"` + AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"` +} + +func (TicketMensaje) TableName() string { return "ticket_mensajes" } + +// ─── CRUD ───────────────────────────────────────────────────────────────────── + +func GetTicketsByProyecto(proyectoID uint) ([]ProyectoTicket, error) { + var items []ProyectoTicket + err := app.Http.Database.DB.Where("proyecto_id = ?", proyectoID). + Preload("Mensajes").Order("created_at DESC").Find(&items).Error + return items, err +} + +func GetTicketByID(id uint) (*ProyectoTicket, error) { + var item ProyectoTicket + err := app.Http.Database.DB.Preload("Mensajes").First(&item, id).Error + return &item, err +} + +func CreateProyectoTicket(t *ProyectoTicket) error { + return app.Http.Database.DB.Create(t).Error +} + +func UpdateTicketEstado(id uint, estado string) error { + return app.Http.Database.DB.Model(&ProyectoTicket{}).Where("id = ?", id). + Update("estado", estado).Error +} + +func CreateTicketMensaje(m *TicketMensaje) error { + return app.Http.Database.DB.Create(m).Error +} + +func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) { + var items []ProyectoTicket + err := app.Http.Database.DB.Where("portal_user_id = ?", portalUserID). + Preload("Mensajes").Order("created_at DESC").Find(&items).Error + return items, err +} diff --git a/rest/controllers/factura_controller.go b/rest/controllers/factura_controller.go new file mode 100644 index 0000000..ab08d70 --- /dev/null +++ b/rest/controllers/factura_controller.go @@ -0,0 +1,203 @@ +package controllers + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +func FacturasIndex(c *fiber.Ctx) error { + return c.Render("facturas", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func LoadFacturas(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + search := c.Query("search", "") + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + items, total, err := models.GetAllFacturas(limit, offset, search) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + clientes, _, _ := models.GetAllClientes(200, 0, "") + proyectos, _, _ := models.GetAllProyectos(200, 0, "") + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + "clientes": clientes, + "proyectos": proyectos, + }) +} + +func CreateFactura(c *fiber.Ctx) error { + type Req struct { + ClienteID uint `json:"cliente_id"` + ProyectoID *uint `json:"proyecto_id"` + Numero string `json:"numero"` + Descripcion string `json:"descripcion"` + Monto float64 `json:"monto"` + Moneda string `json:"moneda"` + Estado string `json:"estado"` + FechaEmision string `json:"fecha_emision"` + FechaVencimiento string `json:"fecha_vencimiento"` + Visible bool `json:"visible"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.ClienteID == 0 { + return c.Status(400).JSON(fiber.Map{"error": "cliente_id es requerido"}) + } + f := &models.Factura{ + ClienteID: req.ClienteID, + ProyectoID: req.ProyectoID, + Numero: req.Numero, + Descripcion: req.Descripcion, + Monto: req.Monto, + Moneda: req.Moneda, + Estado: req.Estado, + Visible: req.Visible, + Notas: req.Notas, + FechaEmision: time.Now(), + } + if req.FechaEmision != "" { + t, err := time.Parse("2006-01-02", req.FechaEmision) + if err == nil { + f.FechaEmision = t + } + } + if req.FechaVencimiento != "" { + t, err := time.Parse("2006-01-02", req.FechaVencimiento) + if err == nil { + f.FechaVencimiento = &t + } + } + if f.Moneda == "" { + f.Moneda = "COP" + } + if f.Estado == "" { + f.Estado = "pendiente" + } + if err := models.CreateFactura(f); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(f) +} + +func UpdateFactura(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + type Req struct { + ClienteID uint `json:"cliente_id"` + ProyectoID *uint `json:"proyecto_id"` + Numero string `json:"numero"` + Descripcion string `json:"descripcion"` + Monto float64 `json:"monto"` + Moneda string `json:"moneda"` + Estado string `json:"estado"` + FechaEmision string `json:"fecha_emision"` + FechaVencimiento string `json:"fecha_vencimiento"` + Visible bool `json:"visible"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + f := &models.Factura{ + ClienteID: req.ClienteID, + ProyectoID: req.ProyectoID, + Numero: req.Numero, + Descripcion: req.Descripcion, + Monto: req.Monto, + Moneda: req.Moneda, + Estado: req.Estado, + Visible: req.Visible, + Notas: req.Notas, + } + f.ID = uint(id) + if req.FechaEmision != "" { + t, err := time.Parse("2006-01-02", req.FechaEmision) + if err == nil { + f.FechaEmision = t + } + } + if req.FechaVencimiento != "" { + t, err := time.Parse("2006-01-02", req.FechaVencimiento) + if err == nil { + f.FechaVencimiento = &t + } + } + if err := models.UpdateFactura(f); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteFactura(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteFactura(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func UploadFacturaPDF(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + file, err := c.FormFile("pdf") + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Archivo PDF requerido"}) + } + if file.Size > 20*1024*1024 { + return c.Status(400).JSON(fiber.Map{"error": "Máximo 20MB"}) + } + dir := "uploads/facturas" + if err := os.MkdirAll(dir, 0755); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"}) + } + safeFile := safeFilenameProyecto(file.Filename) + savePath := filepath.Join(dir, fmt.Sprintf("%d_%s", id, safeFile)) + if err := c.SaveFile(file, savePath); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"}) + } + if err := models.UpdateFacturaArchivo(uint(id), savePath, file.Filename); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true, "archivo": savePath}) +} + +func DownloadFacturaPDF(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + f, err := models.GetFacturaByID(uint(id)) + if err != nil || f.Archivo == "" { + return c.Status(404).JSON(fiber.Map{"error": "Archivo no encontrado"}) + } + clean := filepath.Clean(f.Archivo) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.OriginalName)) + return c.SendFile(clean) +} diff --git a/rest/controllers/portal_controller.go b/rest/controllers/portal_controller.go new file mode 100644 index 0000000..82251f7 --- /dev/null +++ b/rest/controllers/portal_controller.go @@ -0,0 +1,326 @@ +package controllers + +import ( + "fmt" + "net/url" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/rest/middlewares" +) + +// ─── Auth ───────────────────────────────────────────────────────────────────── + +func PortalLoginPage(c *fiber.Ctx) error { + if auth.IsPortalLoggedIn(c) { + return c.Redirect("/portal/dashboard") + } + return c.Render("portal/login", fiber.Map{ + "error": c.Query("error"), + }, "layouts/portal_public") +} + +func PortalLoginPost(c *fiber.Ctx) error { + email := strings.TrimSpace(c.FormValue("email")) + password := c.FormValue("password") + u, err := models.CheckPortalLogin(email, password) + if err != nil { + return c.Redirect("/portal/login?error=" + url.QueryEscape(err.Error())) + } + if err := auth.SetPortalSession(c, u.ID); err != nil { + return c.Redirect("/portal/login?error=Error+interno") + } + return c.Redirect("/portal/dashboard") +} + +func PortalLogout(c *fiber.Ctx) error { + _ = auth.DestroyPortalSession(c) + return c.Redirect("/portal/login") +} + +// ─── Dashboard ──────────────────────────────────────────────────────────────── + +func PortalDashboard(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Redirect("/portal/login") + } + + // Recargar con accesos + fullUser, err := models.GetPortalUserByID(u.ID) + if err != nil { + return c.Redirect("/portal/login") + } + + clienteIDs := models.GetClienteIDsForPortalUser(fullUser) + proyectos, _ := models.GetProyectosByClienteIDs(clienteIDs) + + // Si partner: agrupar proyectos por cliente + type ClienteProyectos struct { + Cliente models.Cliente + Proyectos []models.Proyecto + } + var grupos []ClienteProyectos + clienteMap := map[uint]*ClienteProyectos{} + for _, p := range proyectos { + if _, ok := clienteMap[p.ClienteID]; !ok { + clienteMap[p.ClienteID] = &ClienteProyectos{Cliente: p.Cliente} + } + clienteMap[p.ClienteID].Proyectos = append(clienteMap[p.ClienteID].Proyectos, p) + } + for _, g := range clienteMap { + grupos = append(grupos, *g) + } + + return c.Render("portal/dashboard", fiber.Map{ + "portalUser": fullUser, + "proyectos": proyectos, + "grupos": grupos, + "isPartner": fullUser.Rol == "partner", + }, "layouts/portal") +} + +// ─── Detalle de proyecto ────────────────────────────────────────────────────── + +func PortalProyecto(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Redirect("/portal/login") + } + + slug := c.Params("slug") + proy, err := models.GetProyectoBySlug(slug) + if err != nil { + return c.Status(404).Render("portal/404", fiber.Map{}, "layouts/portal") + } + + // Verificar acceso + fullUser, _ := models.GetPortalUserByID(u.ID) + clienteIDs := models.GetClienteIDsForPortalUser(fullUser) + hasAccess := false + for _, cid := range clienteIDs { + if cid == proy.ClienteID { + hasAccess = true + break + } + } + if !hasAccess { + return c.Status(403).Redirect("/portal/dashboard") + } + + fases, _ := models.GetFasesByProyecto(proy.ID) + avances, _ := models.GetAvancesByProyecto(proy.ID, true) + entregables, _ := models.GetEntregablesByProyecto(proy.ID, true) + tickets, _ := models.GetTicketsByPortalUser(u.ID) + + return c.Render("portal/proyecto", fiber.Map{ + "portalUser": fullUser, + "proyecto": proy, + "fases": fases, + "avances": avances, + "entregables": entregables, + "tickets": tickets, + }, "layouts/portal") +} + +// ─── API del portal ─────────────────────────────────────────────────────────── + +// PortalGetProyectos devuelve los proyectos del usuario logueado (JSON para Alpine). +func PortalGetProyectos(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Status(401).JSON(fiber.Map{"error": "no autenticado"}) + } + fullUser, _ := models.GetPortalUserByID(u.ID) + clienteIDs := models.GetClienteIDsForPortalUser(fullUser) + proyectos, _ := models.GetProyectosByClienteIDs(clienteIDs) + return c.JSON(proyectos) +} + +// PortalGetProyectoData devuelve todo el contenido de un proyecto (JSON). +func PortalGetProyectoData(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Status(401).JSON(fiber.Map{"error": "no autenticado"}) + } + slug := c.Params("slug") + proy, err := models.GetProyectoBySlug(slug) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "no encontrado"}) + } + // Verificar acceso + fullUser, _ := models.GetPortalUserByID(u.ID) + clienteIDs := models.GetClienteIDsForPortalUser(fullUser) + hasAccess := false + for _, cid := range clienteIDs { + if cid == proy.ClienteID { + hasAccess = true + break + } + } + if !hasAccess { + return c.Status(403).JSON(fiber.Map{"error": "sin acceso"}) + } + + fases, _ := models.GetFasesByProyecto(proy.ID) + avances, _ := models.GetAvancesByProyecto(proy.ID, true) + entregables, _ := models.GetEntregablesByProyecto(proy.ID, true) + tickets, _ := models.GetTicketsByPortalUser(u.ID) + facturas, _ := models.GetFacturasByCliente(proy.ClienteID, true) + + return c.JSON(fiber.Map{ + "proyecto": proy, + "fases": fases, + "avances": avances, + "entregables": entregables, + "tickets": tickets, + "facturas": facturas, + }) +} + +// PortalCrearTicket crea un nuevo ticket de soporte. +func PortalCrearTicket(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Status(401).JSON(fiber.Map{"error": "no autenticado"}) + } + type Req struct { + ProyectoSlug string `json:"proyecto_slug"` + Titulo string `json:"titulo"` + Descripcion string `json:"descripcion"` + Prioridad string `json:"prioridad"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if strings.TrimSpace(req.Titulo) == "" { + return c.Status(400).JSON(fiber.Map{"error": "El título es requerido"}) + } + + proy, err := models.GetProyectoBySlug(req.ProyectoSlug) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"}) + } + + t := &models.ProyectoTicket{ + ProyectoID: proy.ID, + PortalUserID: u.ID, + AutorNombre: u.Nombre, + Titulo: req.Titulo, + Descripcion: req.Descripcion, + Prioridad: req.Prioridad, + Estado: "abierto", + } + if t.Prioridad == "" { + t.Prioridad = "media" + } + if err := models.CreateProyectoTicket(t); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(t) +} + +// PortalResponderTicket agrega un mensaje a un ticket. +func PortalResponderTicket(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Status(401).JSON(fiber.Map{"error": "no autenticado"}) + } + ticketID, _ := c.ParamsInt("id") + type Req struct{ Contenido string `json:"contenido"` } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if strings.TrimSpace(req.Contenido) == "" { + return c.Status(400).JSON(fiber.Map{"error": "El mensaje no puede estar vacío"}) + } + ticket, err := models.GetTicketByID(uint(ticketID)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"}) + } + if ticket.PortalUserID != u.ID { + return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"}) + } + msg := &models.TicketMensaje{ + TicketID: uint(ticketID), + Contenido: req.Contenido, + EsAdmin: false, + AutorNombre: u.Nombre, + } + if err := models.CreateTicketMensaje(msg); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(msg) +} + +// PortalDownloadEntregable descarga un entregable del portal. +func PortalDownloadEntregable(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Status(401).JSON(fiber.Map{"error": "no autenticado"}) + } + entID, _ := c.ParamsInt("id") + item, err := models.GetProyectoEntregableByID(uint(entID)) + if err != nil || !item.Visible { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + // Verificar que el proyecto pertenece al cliente del portal user + proy, err := models.GetProyectoByID(item.ProyectoID) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Proyecto no encontrado"}) + } + fullUser, _ := models.GetPortalUserByID(u.ID) + clienteIDs := models.GetClienteIDsForPortalUser(fullUser) + hasAccess := false + for _, cid := range clienteIDs { + if cid == proy.ClienteID { + hasAccess = true + break + } + } + if !hasAccess { + return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"}) + } + clean := item.Archivo + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName)) + return c.SendFile(clean) +} + +// PortalDownloadFactura descarga el PDF de una factura del portal. +func PortalDownloadFactura(c *fiber.Ctx) error { + u := middlewares.PortalUserFromLocals(c) + if u == nil { + return c.Status(401).JSON(fiber.Map{"error": "no autenticado"}) + } + factID, _ := c.ParamsInt("id") + f, err := models.GetFacturaByID(uint(factID)) + if err != nil || !f.Visible || f.Archivo == "" { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + // Verificar acceso + fullUser, _ := models.GetPortalUserByID(u.ID) + clienteIDs := models.GetClienteIDsForPortalUser(fullUser) + hasAccess := false + for _, cid := range clienteIDs { + if cid == f.ClienteID { + hasAccess = true + break + } + } + if !hasAccess { + return c.Status(403).JSON(fiber.Map{"error": "Sin acceso"}) + } + clean := f.Archivo + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.OriginalName)) + return c.SendFile(clean) +} diff --git a/rest/controllers/portal_usuario_controller.go b/rest/controllers/portal_usuario_controller.go new file mode 100644 index 0000000..3d99388 --- /dev/null +++ b/rest/controllers/portal_usuario_controller.go @@ -0,0 +1,153 @@ +package controllers + +import ( + "math" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +func PortalUsuariosIndex(c *fiber.Ctx) error { + return c.Render("portal_usuarios", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func LoadPortalUsuarios(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + items, total, err := models.GetAllPortalUsers(limit, offset) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + clientes, _, _ := models.GetAllClientes(200, 0, "") + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + "clientes": clientes, + }) +} + +func CreatePortalUsuario(c *fiber.Ctx) error { + type Req struct { + Nombre string `json:"nombre"` + Email string `json:"email"` + Password string `json:"password"` + ClienteID *uint `json:"cliente_id"` + Rol string `json:"rol"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if strings.TrimSpace(req.Nombre) == "" || strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" { + return c.Status(400).JSON(fiber.Map{"error": "nombre, email y password son requeridos"}) + } + hashed, err := app.Http.Hash.Create(req.Password) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al hashear contraseña"}) + } + if req.Rol == "" { + req.Rol = "cliente" + } + u := &models.PortalUser{ + Nombre: req.Nombre, + Email: strings.ToLower(strings.TrimSpace(req.Email)), + Password: hashed, + ClienteID: req.ClienteID, + Rol: req.Rol, + Activo: true, + Notas: req.Notas, + } + if err := models.CreatePortalUser(u); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + u.Password = "" + return c.Status(201).JSON(u) +} + +func UpdatePortalUsuario(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + type Req struct { + Nombre string `json:"nombre"` + Email string `json:"email"` + Password string `json:"password"` + ClienteID *uint `json:"cliente_id"` + Rol string `json:"rol"` + Activo bool `json:"activo"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + u := &models.PortalUser{ + Nombre: req.Nombre, + Email: strings.ToLower(strings.TrimSpace(req.Email)), + ClienteID: req.ClienteID, + Rol: req.Rol, + Activo: req.Activo, + Notas: req.Notas, + } + u.ID = uint(id) + if err := models.UpdatePortalUser(u); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + // Actualizar password solo si se envió + if strings.TrimSpace(req.Password) != "" { + hashed, err := app.Http.Hash.Create(req.Password) + if err == nil { + _ = models.UpdatePortalUserPassword(uint(id), hashed) + } + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeletePortalUsuario(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeletePortalUser(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func AddPortalAcceso(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + type Req struct { + ClienteID uint `json:"cliente_id"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.AddPortalAcceso(uint(id), req.ClienteID); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func RemovePortalAcceso(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + clienteID, _ := strconv.ParseUint(c.Params("clienteID"), 10, 32) + if err := models.RemovePortalAcceso(uint(id), uint(clienteID)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/rest/controllers/proyecto_controller.go b/rest/controllers/proyecto_controller.go new file mode 100644 index 0000000..994ceb1 --- /dev/null +++ b/rest/controllers/proyecto_controller.go @@ -0,0 +1,500 @@ +package controllers + +import ( + "fmt" + "math" + "mime/multipart" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Admin pages ────────────────────────────────────────────────────────────── + +func ProyectosIndex(c *fiber.Ctx) error { + return c.Render("proyectos", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func ProyectoDetalleIndex(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Redirect("/app/proyectos") + } + proy, err := models.GetProyectoByID(uint(id)) + if err != nil { + return c.Redirect("/app/proyectos") + } + return c.Render("proyecto_detalle", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + "proyecto": proy, + }, "layouts/main") +} + +// ─── CRUD Proyecto ───────────────────────────────────────────────────────────── + +func LoadProyectos(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + search := c.Query("search", "") + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + items, total, err := models.GetAllProyectos(limit, offset, search) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + // Obtener clientes para el formulario + clientes, _, _ := models.GetAllClientes(200, 0, "") + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + "clientes": clientes, + }) +} + +func CreateProyecto(c *fiber.Ctx) error { + type Req struct { + ClienteID uint `json:"cliente_id"` + Nombre string `json:"nombre"` + Slug string `json:"slug"` + Descripcion string `json:"descripcion"` + Color string `json:"color"` + Estado string `json:"estado"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.ClienteID == 0 || strings.TrimSpace(req.Nombre) == "" { + return c.Status(400).JSON(fiber.Map{"error": "cliente y nombre son requeridos"}) + } + if req.Slug == "" { + req.Slug = slugify(req.Nombre) + } + if req.Color == "" { + req.Color = "#8eb02f" + } + if req.Estado == "" { + req.Estado = "activo" + } + p := &models.Proyecto{ + ClienteID: req.ClienteID, + Nombre: req.Nombre, + Slug: req.Slug, + Descripcion: req.Descripcion, + Color: req.Color, + Estado: req.Estado, + } + if err := models.CreateProyecto(p); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(p) +} + +func UpdateProyecto(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + type Req struct { + ClienteID uint `json:"cliente_id"` + Nombre string `json:"nombre"` + Slug string `json:"slug"` + Descripcion string `json:"descripcion"` + Color string `json:"color"` + Estado string `json:"estado"` + Progreso int `json:"progreso"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + p := &models.Proyecto{ + ClienteID: req.ClienteID, + Nombre: req.Nombre, + Slug: req.Slug, + Descripcion: req.Descripcion, + Color: req.Color, + Estado: req.Estado, + Progreso: req.Progreso, + } + p.ID = uint(id) + if err := models.UpdateProyecto(p); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteProyecto(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteProyecto(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ─── Fases ──────────────────────────────────────────────────────────────────── + +func GetFases(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + fases, err := models.GetFasesByProyecto(uint(id)) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fases) +} + +func CreateFase(c *fiber.Ctx) error { + proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32) + type Req struct { + Nombre string `json:"nombre"` + Descripcion string `json:"descripcion"` + Estado string `json:"estado"` + Orden int `json:"orden"` + FechaEstimada string `json:"fecha_estimada"` + Entregables []string `json:"entregables"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + f := &models.ProyectoFase{ + ProyectoID: uint(proyID), + Nombre: req.Nombre, + Descripcion: req.Descripcion, + Estado: req.Estado, + Orden: req.Orden, + Entregables: req.Entregables, + } + if req.FechaEstimada != "" { + t, err := time.Parse("2006-01-02", req.FechaEstimada) + if err == nil { + f.FechaEstimada = &t + } + } + if err := models.CreateProyectoFase(f); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + f.ParseEntregables() + return c.Status(201).JSON(f) +} + +func UpdateFase(c *fiber.Ctx) error { + faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32) + type Req struct { + Nombre string `json:"nombre"` + Descripcion string `json:"descripcion"` + Estado string `json:"estado"` + Orden int `json:"orden"` + FechaEstimada string `json:"fecha_estimada"` + FechaCompletado string `json:"fecha_completado"` + Entregables []string `json:"entregables"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + f := &models.ProyectoFase{ + Nombre: req.Nombre, + Descripcion: req.Descripcion, + Estado: req.Estado, + Orden: req.Orden, + Entregables: req.Entregables, + } + f.ID = uint(faseID) + if req.FechaEstimada != "" { + t, err := time.Parse("2006-01-02", req.FechaEstimada) + if err == nil { + f.FechaEstimada = &t + } + } + if req.FechaCompletado != "" { + t, err := time.Parse("2006-01-02", req.FechaCompletado) + if err == nil { + f.FechaCompletado = &t + } + } + // Auto-set fecha_completado when estado = completado + if req.Estado == "completado" && f.FechaCompletado == nil { + now := time.Now() + f.FechaCompletado = &now + } + if err := models.UpdateProyectoFase(f); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteFase(c *fiber.Ctx) error { + faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32) + if err := models.DeleteProyectoFase(uint(faseID)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func ApplyTemplate(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + if err := models.ApplyFaseTemplate(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + fases, _ := models.GetFasesByProyecto(uint(id)) + return c.JSON(fases) +} + +// ─── Avances ────────────────────────────────────────────────────────────────── + +func GetAvances(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + avances, err := models.GetAvancesByProyecto(uint(id), false) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(avances) +} + +func CreateAvance(c *fiber.Ctx) error { + proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32) + var a models.ProyectoAvance + if err := c.BodyParser(&a); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + a.ProyectoID = uint(proyID) + if a.Tipo == "" { + a.Tipo = "update" + } + a.Visible = true + if err := models.CreateProyectoAvance(&a); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(a) +} + +func UpdateAvance(c *fiber.Ctx) error { + avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32) + var a models.ProyectoAvance + if err := c.BodyParser(&a); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + a.ID = uint(avID) + if err := models.UpdateProyectoAvance(&a); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteAvance(c *fiber.Ctx) error { + avID, _ := strconv.ParseUint(c.Params("avID"), 10, 32) + if err := models.DeleteProyectoAvance(uint(avID)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ─── Entregables ────────────────────────────────────────────────────────────── + +func GetEntregables(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + items, err := models.GetEntregablesByProyecto(uint(id), false) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +func UploadEntregable(c *fiber.Ctx) error { + proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32) + if _, err := c.MultipartForm(); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Formato multipart requerido"}) + } + nombre := c.FormValue("nombre") + descripcion := c.FormValue("descripcion") + version := c.FormValue("version", "1.0") + + file, err := c.FormFile("archivo") + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Archivo requerido"}) + } + if file.Size > 50*1024*1024 { + return c.Status(400).JSON(fiber.Map{"error": "Archivo máximo 50MB"}) + } + + dir := fmt.Sprintf("uploads/proyectos/%d", proyID) + if err := os.MkdirAll(dir, 0755); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al crear directorio"}) + } + + safeFile := safeFilenameProyecto(file.Filename) + savePath := filepath.Join(dir, safeFile) + if err := c.SaveFile(file, savePath); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error al guardar archivo"}) + } + + e := &models.ProyectoEntregable{ + ProyectoID: uint(proyID), + Nombre: nombre, + Descripcion: descripcion, + Archivo: savePath, + OriginalName: file.Filename, + Version: version, + TipoMime: mimeFromHeader(file), + Tamanio: file.Size, + Visible: true, + } + if err := models.CreateProyectoEntregable(e); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(e) +} + +func DeleteEntregable(c *fiber.Ctx) error { + entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32) + item, err := models.GetProyectoEntregableByID(uint(entID)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + _ = os.Remove(item.Archivo) + if err := models.DeleteProyectoEntregable(uint(entID)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DownloadEntregable(c *fiber.Ctx) error { + entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32) + item, err := models.GetProyectoEntregableByID(uint(entID)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) + } + clean := filepath.Clean(item.Archivo) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.OriginalName)) + return c.SendFile(clean) +} + +func UpdateEntregableVisibilidad(c *fiber.Ctx) error { + entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32) + type Req struct{ Visible bool `json:"visible"` } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.UpdateEntregableVisibilidad(uint(entID), req.Visible); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ─── Tickets (admin) ────────────────────────────────────────────────────────── + +func GetTickets(c *fiber.Ctx) error { + id, _ := strconv.ParseUint(c.Params("id"), 10, 32) + items, err := models.GetTicketsByProyecto(uint(id)) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +func UpdateTicketEstadoAdmin(c *fiber.Ctx) error { + ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32) + type Req struct{ Estado string `json:"estado"` } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if err := models.UpdateTicketEstado(uint(ticketID), req.Estado); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func AdminResponderTicket(c *fiber.Ctx) error { + ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32) + type Req struct{ Contenido string `json:"contenido"` } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if strings.TrimSpace(req.Contenido) == "" { + return c.Status(400).JSON(fiber.Map{"error": "El mensaje no puede estar vacío"}) + } + // Obtener nombre del admin + adminNombre := "Soporte" + if u := c.Locals("user"); u != nil { + if m, ok := u.(map[string]interface{}); ok { + if n, ok := m["Name"].(string); ok && n != "" { + adminNombre = n + } + } + } + msg := &models.TicketMensaje{ + TicketID: uint(ticketID), + Contenido: req.Contenido, + EsAdmin: true, + AutorNombre: adminNombre, + } + if err := models.CreateTicketMensaje(msg); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + // Cambiar estado a en_progreso si estaba abierto + _ = models.UpdateTicketEstado(uint(ticketID), "en_progreso") + return c.Status(201).JSON(msg) +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func slugify(s string) string { + s = strings.ToLower(s) + replacer := strings.NewReplacer( + " ", "-", "á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u", + "ñ", "n", "ü", "u", + ) + s = replacer.Replace(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } + } + return strings.Trim(b.String(), "-") +} + +func safeFilenameProyecto(name string) string { + base := filepath.Base(name) + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + safe := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '_' + }, stem) + ts := strconv.FormatInt(time.Now().UnixNano(), 36) + return safe + "_" + ts + ext +} + +func mimeFromHeader(fh *multipart.FileHeader) string { + ct := fh.Header.Get("Content-Type") + if ct == "" { + return "application/octet-stream" + } + return ct +} diff --git a/rest/middlewares/portal_auth.go b/rest/middlewares/portal_auth.go new file mode 100644 index 0000000..696455e --- /dev/null +++ b/rest/middlewares/portal_auth.go @@ -0,0 +1,35 @@ +package middlewares + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/auth" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// PortalAuth protege las rutas del portal de clientes. +// Si no hay sesión activa, redirige a /portal/login. +func PortalAuth() fiber.Handler { + return func(c *fiber.Ctx) error { + user, err := auth.PortalUser(c) + if err != nil || user == nil { + return c.Redirect("/portal/login") + } + c.Locals("portalUser", user) + return c.Next() + } +} + +// LoadPortalUserMiddleware carga el portal user en locals (sin redirigir). +func LoadPortalUserMiddleware(c *fiber.Ctx) error { + user, err := auth.PortalUser(c) + if err == nil && user != nil { + c.Locals("portalUser", user) + } + return c.Next() +} + +// PortalUserFromLocals extrae el portal user del contexto (helper para controladores). +func PortalUserFromLocals(c *fiber.Ctx) *models.PortalUser { + u, _ := c.Locals("portalUser").(*models.PortalUser) + return u +} diff --git a/rest/routes/portal.go b/rest/routes/portal.go new file mode 100644 index 0000000..81829dc --- /dev/null +++ b/rest/routes/portal.go @@ -0,0 +1,32 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/rest/controllers" + "github.com/sujit-baniya/fiber-boilerplate/rest/middlewares" +) + +func PortalRoutes(app fiber.Router) { + // ─── Rutas públicas ──────────────────────────────────────────────────────── + app.Get("/portal/login", controllers.PortalLoginPage) + app.Post("/portal/login", controllers.PortalLoginPost) + app.Get("/portal/logout", controllers.PortalLogout) + + // ─── Rutas protegidas ────────────────────────────────────────────────────── + portal := app.Group("/portal").Use(middlewares.PortalAuth()) + + portal.Get("/dashboard", controllers.PortalDashboard) + portal.Get("/proyecto/:slug", controllers.PortalProyecto) + + // API JSON para Alpine.js + portal.Get("/api/proyectos", controllers.PortalGetProyectos) + portal.Get("/api/proyecto/:slug", controllers.PortalGetProyectoData) + + // Tickets + portal.Post("/tickets", controllers.PortalCrearTicket) + portal.Post("/tickets/:id/mensaje", controllers.PortalResponderTicket) + + // Descargas + portal.Get("/entregables/:id/download", controllers.PortalDownloadEntregable) + portal.Get("/facturas/:id/download", controllers.PortalDownloadFactura) +} diff --git a/rest/routes/user.go b/rest/routes/user.go index 370484a..b3b8a0b 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -222,6 +222,47 @@ func UserRoutes(app fiber.Router) { protected.Post("/telegram/send", controllers.SendTelegramNotification) protected.Get("/telegram/logs", controllers.GetTelegramLogs) + // ─── Portal de Clientes ──────────────────────────────────────────────────── + protected.Get("/proyectos", middlewares.MenuMiddleware, controllers.ProyectosIndex) + protected.Get("/loadproyectos", controllers.LoadProyectos) + protected.Post("/proyectos", controllers.CreateProyecto) + protected.Put("/proyectos/:id", controllers.UpdateProyecto) + protected.Delete("/proyectos/:id", controllers.DeleteProyecto) + protected.Get("/proyectos/:id/detalle", middlewares.MenuMiddleware, controllers.ProyectoDetalleIndex) + protected.Get("/proyectos/:id/fases", controllers.GetFases) + protected.Post("/proyectos/:id/fases", controllers.CreateFase) + protected.Put("/proyectos/:id/fases/:faseID", controllers.UpdateFase) + protected.Delete("/proyectos/:id/fases/:faseID", controllers.DeleteFase) + protected.Post("/proyectos/:id/fases/template", controllers.ApplyTemplate) + protected.Get("/proyectos/:id/avances", controllers.GetAvances) + protected.Post("/proyectos/:id/avances", controllers.CreateAvance) + protected.Put("/proyectos/:id/avances/:avID", controllers.UpdateAvance) + protected.Delete("/proyectos/:id/avances/:avID", controllers.DeleteAvance) + protected.Get("/proyectos/:id/entregables", controllers.GetEntregables) + protected.Post("/proyectos/:id/entregables", controllers.UploadEntregable) + protected.Delete("/proyectos/:id/entregables/:entID", controllers.DeleteEntregable) + protected.Get("/proyectos/:id/entregables/:entID/download", controllers.DownloadEntregable) + protected.Put("/proyectos/:id/entregables/:entID/visibilidad", controllers.UpdateEntregableVisibilidad) + protected.Get("/proyectos/:id/tickets", controllers.GetTickets) + protected.Put("/proyectos/:id/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin) + protected.Post("/proyectos/:id/tickets/:ticketID/mensaje", controllers.AdminResponderTicket) + + protected.Get("/portal-usuarios", middlewares.MenuMiddleware, controllers.PortalUsuariosIndex) + protected.Get("/loadportalusuarios", controllers.LoadPortalUsuarios) + protected.Post("/portal-usuarios", controllers.CreatePortalUsuario) + protected.Put("/portal-usuarios/:id", controllers.UpdatePortalUsuario) + protected.Delete("/portal-usuarios/:id", controllers.DeletePortalUsuario) + protected.Post("/portal-usuarios/:id/acceso", controllers.AddPortalAcceso) + protected.Delete("/portal-usuarios/:id/acceso/:clienteID", controllers.RemovePortalAcceso) + + protected.Get("/facturas", middlewares.MenuMiddleware, controllers.FacturasIndex) + protected.Get("/loadfacturas", controllers.LoadFacturas) + protected.Post("/facturas", controllers.CreateFactura) + protected.Put("/facturas/:id", controllers.UpdateFactura) + protected.Delete("/facturas/:id", controllers.DeleteFactura) + protected.Post("/facturas/:id/upload-pdf", controllers.UploadFacturaPDF) + protected.Get("/facturas/:id/download", controllers.DownloadFacturaPDF) + // ─── Planes dLocal (gestión desde panel protegido) ──────────────────────── protected.Get("/dlocal/planes", apiControllers.SeePlanes) protected.Post("/dlocal/planes", apiControllers.CreatePlan) diff --git a/rest/routes/web.go b/rest/routes/web.go index dae6cdc..cd6ec7d 100755 --- a/rest/routes/web.go +++ b/rest/routes/web.go @@ -15,5 +15,5 @@ func WebRoutes(web fiber.Router) { LandingRoutes(web) WebAuthRoutes(web) UserRoutes(web) - + PortalRoutes(web) }