- 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 <noreply@anthropic.com>
276 lines
7.5 KiB
Go
276 lines
7.5 KiB
Go
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
|
|
}
|