From bc79553b7e093ab52c0ce48cec2bfe1eafdc6e79 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:18:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(tareas):=20m=C3=B3dulo=20de=20tablero=20ka?= =?UTF-8?q?nban=20con=20drag=20&=20drop,=20comentarios=20y=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modelos Tarea y TareaComentario con GORM, migración automática - Estados: por_hacer → en_progreso → revisión → hecho; prioridades: baja/media/alta/urgente - Drag & drop entre columnas via SortableJS (PUT /tarea/:id/estado) - CRUD completo: crear, editar, eliminar; asignación a usuario + fecha límite - Panel lateral de detalle: historial de comentarios, adjuntar archivos (uploads/tareas/:id) - Telegram: notifica asignación, cambio de estado y comentarios nuevos via sendTelegramAdmin - Seed automático bajo módulo "Administración", solo rol Administrador Co-Authored-By: Claude Sonnet 4.6 --- main.go | 1 + migrations/migrate.go | 39 ++ pkg/models/tarea.go | 85 ++++ pkg/services/notif_dispatch.go | 53 ++ resources/views/tareas.html | 714 +++++++++++++++++++++++++++ rest/controllers/tarea_controller.go | 275 +++++++++++ rest/routes/user.go | 11 + 7 files changed, 1178 insertions(+) create mode 100644 pkg/models/tarea.go create mode 100644 resources/views/tareas.html create mode 100644 rest/controllers/tarea_controller.go diff --git a/main.go b/main.go index 85dfe55..90ffbcf 100755 --- a/main.go +++ b/main.go @@ -121,6 +121,7 @@ func main() { migrations.SeedContabilidadMenu() migrations.SeedWebSms() migrations.SeedUrlMonitor() + migrations.SeedTareas() // Iniciar cron de vencimientos services.IniciarCron() defer services.DetenerCron() diff --git a/migrations/migrate.go b/migrations/migrate.go index a21652c..0bd28bc 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -101,6 +101,9 @@ func Migrate() { // Monitor de disponibilidad de URLs &models.UrlMonitor{}, &models.UrlMonitorLog{}, + // Tablero de tareas + &models.Tarea{}, + &models.TareaComentario{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } @@ -1040,3 +1043,39 @@ func SeedUrlMonitor() { } log.Println("[SEED] Seed de Monitor de URLs completado.") } + +// SeedTareas agrega el submódulo "Tareas" al módulo "Administración" solo para el rol Administrador. Es idempotente. +func SeedTareas() { + db := app.Http.Database.DB + var modulo models.Modules + if err := db.Where("title = ?", "Administración").First(&modulo).Error; err != nil { + log.Println("[SEED] Módulo 'Administración' no encontrado, se omite SeedTareas") + return + } + url := "/app/tareas" + var sub models.Submodules + if err := db.Where("url = ?", url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: "Tareas", + Description: "Tablero kanban de tareas internas", + Url: url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo 'Tareas': %v", err) + return + } + log.Printf("[SEED] Submódulo 'Tareas' creado") + } else if sub.ModuleId != modulo.ID { + db.Model(&sub).Update("module_id", modulo.ID) + } + var rol models.Roles + if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil { + log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err) + } else { + db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub}) + log.Printf("[SEED] Submódulo 'Tareas' asignado al rol 'Administrador'") + } + log.Println("[SEED] Seed de Tareas completado.") +} diff --git a/pkg/models/tarea.go b/pkg/models/tarea.go new file mode 100644 index 0000000..6374134 --- /dev/null +++ b/pkg/models/tarea.go @@ -0,0 +1,85 @@ +package models + +import ( + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +type Tarea struct { + gorm.Model + Titulo string `json:"titulo" gorm:"column:titulo;size:200"` + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + Estado string `json:"estado" gorm:"column:estado;default:'por_hacer';index"` + Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` + AsignadoID *uint `json:"asignado_id" gorm:"column:asignado_id;index"` + Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoID"` + CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"` + CreadoPor *Users `json:"creado_por" gorm:"foreignKey:CreadoPorID"` + FechaLimite *time.Time `json:"fecha_limite" gorm:"column:fecha_limite"` + Orden int `json:"orden" gorm:"column:orden;default:0"` +} + +func (Tarea) TableName() string { return "tarea" } + +type TareaComentario struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + TareaID uint `json:"tarea_id" gorm:"column:tarea_id;index"` + AutorID uint `json:"autor_id" gorm:"column:autor_id"` + Autor *Users `json:"autor" gorm:"foreignKey:AutorID"` + Contenido string `json:"contenido" gorm:"column:contenido;type:text"` + Archivos string `json:"archivos" gorm:"column:archivos;type:text"` + CreatedAt time.Time `json:"created_at"` +} + +func (TareaComentario) TableName() string { return "tarea_comentario" } + +func GetAllTareas() ([]Tarea, error) { + var items []Tarea + err := app.Http.Database.DB. + Preload("Asignado"). + Preload("CreadoPor"). + Order("estado ASC, orden ASC, created_at ASC"). + Find(&items).Error + return items, err +} + +func GetTareaByID(id uint) (*Tarea, error) { + var t Tarea + err := app.Http.Database.DB. + Preload("Asignado"). + Preload("CreadoPor"). + First(&t, id).Error + return &t, err +} + +func CreateTarea(t *Tarea) error { + return app.Http.Database.DB.Create(t).Error +} + +func SaveTarea(t *Tarea) error { + return app.Http.Database.DB.Save(t).Error +} + +func DeleteTarea(id uint) error { + return app.Http.Database.DB.Delete(&Tarea{}, id).Error +} + +func CambiarEstadoTarea(id uint, estado string) error { + return app.Http.Database.DB.Model(&Tarea{}).Where("id = ?", id).Update("estado", estado).Error +} + +func GetComentariosByTarea(tareaID uint) ([]TareaComentario, error) { + var items []TareaComentario + err := app.Http.Database.DB. + Preload("Autor"). + Where("tarea_id = ?", tareaID). + Order("created_at ASC"). + Find(&items).Error + return items, err +} + +func CreateComentario(c *TareaComentario) error { + return app.Http.Database.DB.Create(c).Error +} diff --git a/pkg/services/notif_dispatch.go b/pkg/services/notif_dispatch.go index f9d3cea..afefdb7 100644 --- a/pkg/services/notif_dispatch.go +++ b/pkg/services/notif_dispatch.go @@ -254,6 +254,59 @@ func NotificarReniceAction(servidorNombre, accion string) { sendTelegramAdmin(msg) } +// ─── Tareas ────────────────────────────────────────────────────────────────── + +func NotificarTareaAsignada(t *models.Tarea) { + if t == nil || t.AsignadoID == nil { + return + } + asignado := "" + if t.Asignado != nil { + asignado = t.Asignado.Name + } + msg := fmt.Sprintf("📋 Nueva tarea asignada\n%s\nAsignado a: %s\nPrioridad: %s\n\n%s", + escapeTelegramHTML(t.Titulo), + escapeTelegramHTML(asignado), + escapeTelegramHTML(t.Prioridad), + escapeTelegramHTML(t.Descripcion)) + sendTelegramAdmin(msg) +} + +func NotificarTareaEstado(t *models.Tarea) { + if t == nil { + return + } + etiquetas := map[string]string{ + "por_hacer": "📥 Por hacer", + "en_progreso": "🔄 En progreso", + "revision": "🔍 En revisión", + "hecho": "✅ Hecho", + } + label := etiquetas[t.Estado] + if label == "" { + label = t.Estado + } + asignado := "" + if t.Asignado != nil { + asignado = "\nAsignado: " + escapeTelegramHTML(t.Asignado.Name) + } + msg := fmt.Sprintf("🔀 Tarea movida → %s\n%s%s", + label, + escapeTelegramHTML(t.Titulo), + asignado) + sendTelegramAdmin(msg) +} + +func NotificarTareaComentario(t *models.Tarea, contenido string) { + if t == nil { + return + } + msg := fmt.Sprintf("💬 Nuevo comentario en tarea\n%s\n\n%s", + escapeTelegramHTML(t.Titulo), + escapeTelegramHTML(contenido)) + sendTelegramAdmin(msg) +} + func sendTelegramAdmin(mensaje string) { configs, err := models.GetAllTelegramConfigs() if err != nil { diff --git a/resources/views/tareas.html b/resources/views/tareas.html new file mode 100644 index 0000000..6aba72f --- /dev/null +++ b/resources/views/tareas.html @@ -0,0 +1,714 @@ + + +
+ + +
+
+

Tablero de Tareas

+

Organiza y asigna tareas al equipo.

+
+
+ + + +
+
+ + +
+
+ + +
+ + + + Cargando tablero... +
+ + +
+ +
+ + + + +
+
+
+

+ +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ + + + +
+
+ +
+ + + +
+ + +
+
+ + +
diff --git a/rest/controllers/tarea_controller.go b/rest/controllers/tarea_controller.go new file mode 100644 index 0000000..47a9201 --- /dev/null +++ b/rest/controllers/tarea_controller.go @@ -0,0 +1,275 @@ +package controllers + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +func TareasIndex(c *fiber.Ctx) error { + return c.Render("tareas", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func GetTareas(c *fiber.Ctx) error { + items, err := models.GetAllTareas() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +func GetUsuariosSistema(c *fiber.Ctx) error { + users, _, err := models.AllUsersSistema(100, 0, "") + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(users) +} + +func CreateTarea(c *fiber.Ctx) error { + var req struct { + Titulo string `json:"titulo"` + Descripcion string `json:"descripcion"` + Prioridad string `json:"prioridad"` + AsignadoID *uint `json:"asignado_id"` + FechaLimite *string `json:"fecha_limite"` + } + 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": "titulo requerido"}) + } + + autorID := extraerUserID(c) + + tarea := &models.Tarea{ + Titulo: req.Titulo, + Descripcion: req.Descripcion, + Estado: "por_hacer", + Prioridad: prioPorDefecto(req.Prioridad), + AsignadoID: req.AsignadoID, + CreadoPorID: autorID, + } + if req.FechaLimite != nil && *req.FechaLimite != "" { + if t, err := time.Parse("2006-01-02", *req.FechaLimite); err == nil { + tarea.FechaLimite = &t + } + } + if err := models.CreateTarea(tarea); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + // reload con preloads + t, _ := models.GetTareaByID(tarea.ID) + go services.NotificarTareaAsignada(t) + return c.Status(201).JSON(t) +} + +func UpdateTarea(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"}) + } + tarea, err := models.GetTareaByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "no encontrado"}) + } + + var req struct { + Titulo string `json:"titulo"` + Descripcion string `json:"descripcion"` + Prioridad string `json:"prioridad"` + AsignadoID *uint `json:"asignado_id"` + FechaLimite *string `json:"fecha_limite"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + prevAsignado := tarea.AsignadoID + tarea.Titulo = req.Titulo + tarea.Descripcion = req.Descripcion + tarea.Prioridad = prioPorDefecto(req.Prioridad) + tarea.AsignadoID = req.AsignadoID + tarea.FechaLimite = nil + if req.FechaLimite != nil && *req.FechaLimite != "" { + if t, err := time.Parse("2006-01-02", *req.FechaLimite); err == nil { + tarea.FechaLimite = &t + } + } + if err := models.SaveTarea(tarea); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + t, _ := models.GetTareaByID(tarea.ID) + // notificar si cambió el asignado + if cambioPuntero(prevAsignado, req.AsignadoID) { + go services.NotificarTareaAsignada(t) + } + return c.JSON(t) +} + +func CambiarEstadoTarea(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"}) + } + var req struct { + Estado string `json:"estado"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + estados := map[string]bool{"por_hacer": true, "en_progreso": true, "revision": true, "hecho": true} + if !estados[req.Estado] { + return c.Status(400).JSON(fiber.Map{"error": "estado inválido"}) + } + if err := models.CambiarEstadoTarea(uint(id), req.Estado); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + t, _ := models.GetTareaByID(uint(id)) + go services.NotificarTareaEstado(t) + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteTareaHandler(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.DeleteTarea(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func GetTareaDetalle(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"}) + } + tarea, err := models.GetTareaByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "no encontrado"}) + } + comentarios, _ := models.GetComentariosByTarea(uint(id)) + return c.JSON(fiber.Map{"tarea": tarea, "comentarios": comentarios}) +} + +func AddComentario(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"}) + } + tarea, err := models.GetTareaByID(uint(id)) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "tarea no encontrada"}) + } + + contenido := strings.TrimSpace(c.FormValue("contenido")) + if contenido == "" { + return c.Status(400).JSON(fiber.Map{"error": "contenido requerido"}) + } + + autorID := extraerUserID(c) + archivosJSON := "[]" + + // archivo adjunto opcional + file, fileErr := c.FormFile("archivo") + if fileErr == nil && file != nil { + if file.Size > 50*1024*1024 { + return c.Status(400).JSON(fiber.Map{"error": "Máximo 50MB por archivo"}) + } + dir := fmt.Sprintf("uploads/tareas/%d", id) + if err := os.MkdirAll(dir, 0755); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error creando directorio"}) + } + safeFile := safeTareaFilename(file.Filename) + savePath := filepath.Join(dir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), safeFile)) + clean := filepath.Clean(savePath) + if !strings.HasPrefix(clean, "uploads/") { + return c.Status(400).JSON(fiber.Map{"error": "Ruta inválida"}) + } + if err := c.SaveFile(file, savePath); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Error guardando archivo"}) + } + archivosJSON = fmt.Sprintf(`[{"nombre":"%s","ruta":"%s"}]`, + escapeJSON(file.Filename), escapeJSON(savePath)) + } + + comentario := &models.TareaComentario{ + TareaID: uint(id), + AutorID: autorID, + Contenido: contenido, + Archivos: archivosJSON, + } + if err := models.CreateComentario(comentario); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + go services.NotificarTareaComentario(tarea, contenido) + return c.Status(201).JSON(comentario) +} + +func extraerUserID(c *fiber.Ctx) uint { + userMap, _ := c.Locals("user").(map[string]interface{}) + if userMap == nil { + return 0 + } + switch v := userMap["id"].(type) { + case float64: + return uint(v) + case uint: + return v + } + return 0 +} + +func prioPorDefecto(p string) string { + valid := map[string]bool{"baja": true, "media": true, "alta": true, "urgente": true} + if valid[p] { + return p + } + return "media" +} + +func cambioPuntero(a, b *uint) bool { + if a == nil && b == nil { + return false + } + if a == nil || b == nil { + return true + } + return *a != *b +} + +func safeTareaFilename(name string) string { + base := filepath.Base(name) + var out []rune + for _, r := range base { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' { + out = append(out, r) + } else { + out = append(out, '_') + } + } + if len(out) == 0 { + return "archivo" + } + return string(out) +} + +func escapeJSON(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} diff --git a/rest/routes/user.go b/rest/routes/user.go index a9a6745..02cd0b1 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -84,6 +84,17 @@ func UserRoutes(app fiber.Router) { protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory) protected.Post("/servidor/:id/sync-hostinger", controllers.SyncServidorFromHostinger) + // Tareas (kanban) + protected.Get("/tareas", middlewares.MenuMiddleware, controllers.TareasIndex) + protected.Get("/tareas/data", controllers.GetTareas) + protected.Get("/tareas/usuarios", controllers.GetUsuariosSistema) + protected.Post("/tarea", controllers.CreateTarea) + protected.Get("/tarea/:id", controllers.GetTareaDetalle) + protected.Put("/tarea/:id", controllers.UpdateTarea) + protected.Put("/tarea/:id/estado", controllers.CambiarEstadoTarea) + protected.Delete("/tarea/:id", controllers.DeleteTareaHandler) + protected.Post("/tarea/:id/comentario", controllers.AddComentario) + // Monitor de URLs protected.Get("/url-monitor", middlewares.MenuMiddleware, controllers.UrlMonitorIndex) protected.Get("/url-monitors", controllers.GetUrlMonitors)