package handlers import ( "net/http" "strconv" "github.com/labstack/echo/v4" "github.com/treytartt/honeydue-api/internal/apperrors" "github.com/treytartt/honeydue-api/internal/dto/requests" "github.com/treytartt/honeydue-api/internal/middleware" "github.com/treytartt/honeydue-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 echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) // Auto-capture timezone from header for background job calculations (e.g., daily digest) // Only write to DB if the timezone has actually changed from the cached value if tzHeader := c.Request().Header.Get("X-Timezone"); tzHeader != "" { cachedTZ, _ := c.Get("user_timezone").(string) if cachedTZ != tzHeader { h.taskService.UpdateUserTimezone(c.Request().Context(), user.ID, tzHeader) c.Set("user_timezone", tzHeader) } } daysThreshold := 30 // Support "days" param first, fall back to "days_threshold" for backward compatibility if d := c.QueryParam("days"); d != "" { if parsed, err := strconv.Atoi(d); err == nil { if parsed < 1 || parsed > 3650 { return apperrors.BadRequest("error.days_out_of_range") } daysThreshold = parsed } } else if d := c.QueryParam("days_threshold"); d != "" { if parsed, err := strconv.Atoi(d); err == nil { if parsed < 1 || parsed > 3650 { return apperrors.BadRequest("error.days_out_of_range") } daysThreshold = parsed } } response, err := h.taskService.ListTasks(c.Request().Context(), user.ID, daysThreshold, userNow) if err != nil { return err } return c.JSON(http.StatusOK, response) } // GetTask handles GET /api/tasks/:id/ func (h *TaskHandler) GetTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.GetTask(c.Request().Context(), uint(taskID), user.ID) if err != nil { return err } return c.JSON(http.StatusOK, response) } // GetTasksByResidence handles GET /api/tasks/by-residence/:residence_id/ func (h *TaskHandler) GetTasksByResidence(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_residence_id") } daysThreshold := 30 // Support "days" param first, fall back to "days_threshold" for backward compatibility if d := c.QueryParam("days"); d != "" { if parsed, err := strconv.Atoi(d); err == nil { if parsed < 1 || parsed > 3650 { return apperrors.BadRequest("error.days_out_of_range") } daysThreshold = parsed } } else if d := c.QueryParam("days_threshold"); d != "" { if parsed, err := strconv.Atoi(d); err == nil { if parsed < 1 || parsed > 3650 { return apperrors.BadRequest("error.days_out_of_range") } daysThreshold = parsed } } response, err := h.taskService.GetTasksByResidence(c.Request().Context(), uint(residenceID), user.ID, daysThreshold, userNow) if err != nil { return err } return c.JSON(http.StatusOK, response) } // CreateTask handles POST /api/tasks/ func (h *TaskHandler) CreateTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) var req requests.CreateTaskRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return err } response, err := h.taskService.CreateTask(c.Request().Context(), &req, user.ID, userNow) if err != nil { return err } return c.JSON(http.StatusCreated, response) } // BulkCreateTasks handles POST /api/tasks/bulk/ for onboarding and other // flows that need to insert 1-N tasks atomically. The entire batch either // commits or rolls back; clients never see a partial state. func (h *TaskHandler) BulkCreateTasks(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) var req requests.BulkCreateTasksRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return err } response, err := h.taskService.BulkCreateTasks(c.Request().Context(), &req, user.ID, userNow) if err != nil { return err } return c.JSON(http.StatusCreated, response) } // UpdateTask handles PUT/PATCH /api/tasks/:id/ func (h *TaskHandler) UpdateTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } var req requests.UpdateTaskRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return err } response, err := h.taskService.UpdateTask(c.Request().Context(), uint(taskID), user.ID, &req, userNow) if err != nil { return err } return c.JSON(http.StatusOK, response) } // DeleteTask handles DELETE /api/tasks/:id/ func (h *TaskHandler) DeleteTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.DeleteTask(c.Request().Context(), uint(taskID), user.ID) if err != nil { return err } return c.JSON(http.StatusOK, response) } // MarkInProgress handles POST /api/tasks/:id/mark-in-progress/ func (h *TaskHandler) MarkInProgress(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.MarkInProgress(c.Request().Context(), uint(taskID), user.ID, userNow) if err != nil { return err } return c.JSON(http.StatusOK, response) } // CancelTask handles POST /api/tasks/:id/cancel/ func (h *TaskHandler) CancelTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.CancelTask(c.Request().Context(), uint(taskID), user.ID, userNow) if err != nil { return err } return c.JSON(http.StatusOK, response) } // UncancelTask handles POST /api/tasks/:id/uncancel/ func (h *TaskHandler) UncancelTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.UncancelTask(c.Request().Context(), uint(taskID), user.ID, userNow) if err != nil { return err } return c.JSON(http.StatusOK, response) } // ArchiveTask handles POST /api/tasks/:id/archive/ func (h *TaskHandler) ArchiveTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.ArchiveTask(c.Request().Context(), uint(taskID), user.ID, userNow) if err != nil { return err } return c.JSON(http.StatusOK, response) } // UnarchiveTask handles POST /api/tasks/:id/unarchive/ func (h *TaskHandler) UnarchiveTask(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.UnarchiveTask(c.Request().Context(), uint(taskID), user.ID, userNow) if err != nil { return err } 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 echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } err = h.taskService.QuickComplete(c.Request().Context(), uint(taskID), user.ID) if err != nil { return err } return c.NoContent(http.StatusOK) } // === Task Completions === // GetTaskCompletions handles GET /api/tasks/:id/completions/ func (h *TaskHandler) GetTaskCompletions(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_task_id") } response, err := h.taskService.GetCompletionsByTask(c.Request().Context(), uint(taskID), user.ID) if err != nil { return err } return c.JSON(http.StatusOK, response) } // ListCompletions handles GET /api/task-completions/ func (h *TaskHandler) ListCompletions(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } response, err := h.taskService.ListCompletions(c.Request().Context(), user.ID) if err != nil { return err } return c.JSON(http.StatusOK, response) } // GetCompletion handles GET /api/task-completions/:id/ func (h *TaskHandler) GetCompletion(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } completionID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_completion_id") } response, err := h.taskService.GetCompletion(c.Request().Context(), uint(completionID), user.ID) if err != nil { return err } return c.JSON(http.StatusOK, response) } // CreateCompletion handles POST /api/task-completions/ // // JSON-only. Image attachments arrive via the presigned-URL flow: // // 1. Client POSTs /api/uploads/presign for each image and uploads bytes // directly to B2 using the returned policy. // 2. Client POSTs the resulting upload_ids[] in this request body. // 3. The service claims those pending_uploads rows and creates the // associated TaskCompletionImage rows. // // The legacy multipart path (with the API server proxying image bytes) // was removed alongside the 1 MB BodyLimit middleware that was rejecting // it anyway. See deploy-k3s/manifests/b2-lifecycle.md. func (h *TaskHandler) CreateCompletion(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } userNow := middleware.GetUserNow(c) var req requests.CreateTaskCompletionRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return err } response, err := h.taskService.CreateCompletion(c.Request().Context(), &req, user.ID, userNow) if err != nil { return err } return c.JSON(http.StatusCreated, response) } // UpdateCompletion handles PUT /api/task-completions/:id/ func (h *TaskHandler) UpdateCompletion(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } completionID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_completion_id") } var req requests.UpdateTaskCompletionRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return err } response, err := h.taskService.UpdateCompletion(c.Request().Context(), uint(completionID), user.ID, &req) if err != nil { return err } return c.JSON(http.StatusOK, response) } // DeleteCompletion handles DELETE /api/task-completions/:id/ func (h *TaskHandler) DeleteCompletion(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } completionID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { return apperrors.BadRequest("error.invalid_completion_id") } response, err := h.taskService.DeleteCompletion(c.Request().Context(), uint(completionID), user.ID) if err != nil { return err } return c.JSON(http.StatusOK, response) } // === Lookups === // GetCategories handles GET /api/tasks/categories/ func (h *TaskHandler) GetCategories(c echo.Context) error { categories, err := h.taskService.GetCategories(c.Request().Context()) if err != nil { return err } return c.JSON(http.StatusOK, categories) } // GetPriorities handles GET /api/tasks/priorities/ func (h *TaskHandler) GetPriorities(c echo.Context) error { priorities, err := h.taskService.GetPriorities(c.Request().Context()) if err != nil { return err } return c.JSON(http.StatusOK, priorities) } // GetFrequencies handles GET /api/tasks/frequencies/ func (h *TaskHandler) GetFrequencies(c echo.Context) error { frequencies, err := h.taskService.GetFrequencies(c.Request().Context()) if err != nil { return err } return c.JSON(http.StatusOK, frequencies) }