Files
honeyDueAPI/internal/handlers/task_handler.go
Trey t c51f1ce34a Fix timezone bug in task kanban categorization
Task creation/update responses were using UTC time for kanban column
categorization, causing tasks to incorrectly appear as overdue when
the server had passed midnight UTC but the user's local time was still
the previous day.

Changes:
- Add timezone-aware response functions (NewTaskResponseWithTime, etc.)
- Pass userNow from middleware to all task service methods
- Update handlers to use timezone-aware time from X-Timezone header
- Update tests to pass the now parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:56:02 -06:00

549 lines
18 KiB
Go

package handlers
import (
"errors"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
)
// TaskHandler handles task-related HTTP requests
type TaskHandler struct {
taskService *services.TaskService
storageService *services.StorageService
}
// NewTaskHandler creates a new task handler
func NewTaskHandler(taskService *services.TaskService, storageService *services.StorageService) *TaskHandler {
return &TaskHandler{
taskService: taskService,
storageService: storageService,
}
}
// ListTasks handles GET /api/tasks/
func (h *TaskHandler) ListTasks(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
response, err := h.taskService.ListTasks(user.ID, userNow)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
// GetTask handles GET /api/tasks/:id/
func (h *TaskHandler) GetTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.GetTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// GetTasksByResidence handles GET /api/tasks/by-residence/:residence_id/
func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
}
daysThreshold := 30
if d := c.Query("days_threshold"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil {
daysThreshold = parsed
}
}
response, err := h.taskService.GetTasksByResidence(uint(residenceID), user.ID, daysThreshold, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// CreateTask handles POST /api/tasks/
func (h *TaskHandler) CreateTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
var req requests.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
response, err := h.taskService.CreateTask(&req, user.ID, userNow)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
}
// UpdateTask handles PUT/PATCH /api/tasks/:id/
func (h *TaskHandler) UpdateTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
var req requests.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
response, err := h.taskService.UpdateTask(uint(taskID), user.ID, &req, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// DeleteTask handles DELETE /api/tasks/:id/
func (h *TaskHandler) DeleteTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.DeleteTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// MarkInProgress handles POST /api/tasks/:id/mark-in-progress/
func (h *TaskHandler) MarkInProgress(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.MarkInProgress(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// CancelTask handles POST /api/tasks/:id/cancel/
func (h *TaskHandler) CancelTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.CancelTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
case errors.Is(err, services.ErrTaskAlreadyCancelled):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_cancelled")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// UncancelTask handles POST /api/tasks/:id/uncancel/
func (h *TaskHandler) UncancelTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.UncancelTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// ArchiveTask handles POST /api/tasks/:id/archive/
func (h *TaskHandler) ArchiveTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.ArchiveTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
case errors.Is(err, services.ErrTaskAlreadyArchived):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_archived")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// UnarchiveTask handles POST /api/tasks/:id/unarchive/
func (h *TaskHandler) UnarchiveTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.UnarchiveTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// QuickComplete handles POST /api/tasks/:id/quick-complete/
// Lightweight endpoint for widget - just returns 200 OK on success
func (h *TaskHandler) QuickComplete(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
err = h.taskService.QuickComplete(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.Status(http.StatusOK)
}
// === Task Completions ===
// GetTaskCompletions handles GET /api/tasks/:id/completions/
func (h *TaskHandler) GetTaskCompletions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
}
response, err := h.taskService.GetCompletionsByTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// ListCompletions handles GET /api/task-completions/
func (h *TaskHandler) ListCompletions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
response, err := h.taskService.ListCompletions(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
// GetCompletion handles GET /api/task-completions/:id/
func (h *TaskHandler) GetCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
completionID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")})
return
}
response, err := h.taskService.GetCompletion(uint(completionID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrCompletionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// CreateCompletion handles POST /api/task-completions/
// Supports both JSON and multipart form data (for image uploads)
func (h *TaskHandler) CreateCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
var req requests.CreateTaskCompletionRequest
contentType := c.GetHeader("Content-Type")
// Check if this is a multipart form request (image upload)
if strings.HasPrefix(contentType, "multipart/form-data") {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_parse_form")})
return
}
// Parse task_id (required)
taskIDStr := c.PostForm("task_id")
if taskIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_id_required")})
return
}
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id_value")})
return
}
req.TaskID = uint(taskID)
// Parse notes (optional)
req.Notes = c.PostForm("notes")
// Parse actual_cost (optional)
if costStr := c.PostForm("actual_cost"); costStr != "" {
cost, err := decimal.NewFromString(costStr)
if err == nil {
req.ActualCost = &cost
}
}
// Parse completed_at (optional)
if completedAtStr := c.PostForm("completed_at"); completedAtStr != "" {
if t, err := time.Parse(time.RFC3339, completedAtStr); err == nil {
req.CompletedAt = &t
}
}
// Handle image upload (look for "images" or "image" or "photo" field)
var imageFile interface{}
for _, fieldName := range []string{"images", "image", "photo"} {
if file, err := c.FormFile(fieldName); err == nil {
imageFile = file
break
}
}
if imageFile != nil {
file := imageFile.(*multipart.FileHeader)
if h.storageService != nil {
result, err := h.storageService.Upload(file, "completions")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_upload_image")})
return
}
req.ImageURLs = append(req.ImageURLs, result.URL)
}
}
} else {
// Standard JSON request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
response, err := h.taskService.CreateCompletion(&req, user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusCreated, response)
}
// DeleteCompletion handles DELETE /api/task-completions/:id/
func (h *TaskHandler) DeleteCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
completionID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")})
return
}
response, err := h.taskService.DeleteCompletion(uint(completionID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrCompletionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// === Lookups ===
// GetCategories handles GET /api/tasks/categories/
func (h *TaskHandler) GetCategories(c *gin.Context) {
categories, err := h.taskService.GetCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, categories)
}
// GetPriorities handles GET /api/tasks/priorities/
func (h *TaskHandler) GetPriorities(c *gin.Context) {
priorities, err := h.taskService.GetPriorities()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, priorities)
}
// GetFrequencies handles GET /api/tasks/frequencies/
func (h *TaskHandler) GetFrequencies(c *gin.Context) {
frequencies, err := h.taskService.GetFrequencies()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, frequencies)
}