package controllers import ( "fmt" "math" "mime/multipart" "net/url" "os" "path/filepath" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // ─── 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 interface{} `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()}) } clienteID := parseUintField(req.ClienteID) if 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: 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 interface{} `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()}) } clienteID := parseUintField(req.ClienteID) p := &models.Proyecto{ ClienteID: 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() _ = models.ActualizarProgresoProyecto(uint(proyID)) return c.Status(201).JSON(f) } func UpdateFase(c *fiber.Ctx) error { faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32) 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"` 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()}) } _ = models.ActualizarProgresoProyecto(uint(proyID)) return c.JSON(fiber.Map{"ok": true}) } func DeleteFase(c *fiber.Ctx) error { faseID, _ := strconv.ParseUint(c.Params("faseID"), 10, 32) proyID, _ := strconv.ParseUint(c.Params("id"), 10, 32) if err := models.DeleteProyectoFase(uint(faseID)); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } _ = models.ActualizarProgresoProyecto(uint(proyID)) 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()}) } _ = models.ActualizarProgresoProyecto(uint(id)) 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}) } // ─── Documentos (contratos / órdenes de servicio) ───────────────────────────── func GetDocumentos(c *fiber.Ctx) error { id, _ := strconv.ParseUint(c.Params("id"), 10, 32) items, err := models.GetDocumentosByProyecto(uint(id)) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(items) } func UploadDocumento(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 := strings.TrimSpace(c.FormValue("nombre")) descripcion := c.FormValue("descripcion") tipo := c.FormValue("tipo", "contrato") validTipos := map[string]bool{"contrato": true, "orden_servicio": true, "otro": true} if !validTipos[tipo] { tipo = "otro" } 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"}) } if nombre == "" { nombre = file.Filename } dir := fmt.Sprintf("uploads/proyectos/%d/docs", 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"}) } d := &models.ProyectoDocumento{ ProyectoID: uint(proyID), Tipo: tipo, Nombre: nombre, Descripcion: descripcion, Archivo: savePath, OriginalName: file.Filename, TipoMime: mimeFromHeader(file), Tamanio: file.Size, } if err := models.CreateProyectoDocumento(d); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.Status(201).JSON(d) } func DeleteDocumento(c *fiber.Ctx) error { docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32) item, err := models.GetProyectoDocumentoByID(uint(docID)) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "No encontrado"}) } _ = os.Remove(item.Archivo) if err := models.DeleteProyectoDocumento(uint(docID)); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true}) } func DownloadDocumento(c *fiber.Ctx) error { docID, _ := strconv.ParseUint(c.Params("docID"), 10, 32) item, err := models.GetProyectoDocumentoByID(uint(docID)) 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"}) } nombre := item.OriginalName if nombre == "" { nombre = item.Nombre } c.Set("Content-Disposition", attachmentDisposition(nombre)) return c.SendFile(clean) } // attachmentDisposition genera un header Content-Disposition con fallback ASCII y encoding RFC 5987. func attachmentDisposition(name string) string { safe := strings.Map(func(r rune) rune { if r > 127 || r == '"' || r == '\\' || r == '/' || r == '\n' || r == '\r' { return '_' } return r }, name) return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, safe, url.PathEscape(name)) } // ─── 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") // Notificar al cliente go func() { ticket, err := models.GetTicketByID(uint(ticketID)) if err != nil { return } proyNombre := "" if proy, err := models.GetProyectoByID(ticket.ProyectoID); err == nil { proyNombre = proy.Nombre } services.DispatchTicketRespuestaAdmin(ticket, req.Contenido, proyNombre) }() return c.Status(201).JSON(msg) } // ─── Tickets global (admin) ────────────────────────────────────────────────── func TicketsAdminIndex(c *fiber.Ctx) error { return c.Render("tickets_admin", fiber.Map{ "user": c.Locals("user"), "modules": c.Locals("userModules"), }, "layouts/main") } func GetAllTicketsAdmin(c *fiber.Ctx) error { estado := c.Query("estado", "todos") items, err := models.GetAllTickets(estado) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(items) } // ─── 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(), "-") } // parseUintField convierte interface{} (string o float64 del JSON) a uint de forma segura. func parseUintField(v interface{}) uint { switch val := v.(type) { case float64: return uint(val) case string: n, _ := strconv.ParseUint(strings.TrimSpace(val), 10, 64) return uint(n) } return 0 } 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 }