Files
honeyDueAPI/internal/handlers/task_handler.go
T
Trey t 65a9aae4e5
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Migrate TaskService + ResidenceService to ctx-aware repos
Every public method on TaskService and ResidenceService now takes
ctx context.Context as the first arg and routes its repo calls through
.WithContext(ctx). With otelgorm registered, this means every API
endpoint backed by these two services produces a flame graph in Jaeger
where the SQL spans nest under the parent HTTP request span — instead
of appearing as orphaned queries.

Endpoints now fully traced (HTTP → service → SQL):
- GET    /api/tasks/                       (already shipped)
- GET    /api/tasks/by-residence/:id/      (already shipped)
- GET    /api/tasks/:id/
- POST   /api/tasks/
- POST   /api/tasks/bulk/
- PUT    /api/tasks/:id/
- DELETE /api/tasks/:id/
- POST   /api/tasks/:id/in-progress/
- POST   /api/tasks/:id/cancel/
- POST   /api/tasks/:id/uncancel/
- POST   /api/tasks/:id/archive/
- POST   /api/tasks/:id/unarchive/
- POST   /api/tasks/:id/complete/
- POST   /api/tasks/:id/quick-complete/
- GET    /api/tasks/completions/* (CRUD)
- GET    /api/static_data/ (categories, priorities, frequencies)
- GET    /api/residences/
- GET    /api/residences/my/
- GET    /api/residences/summary/
- GET    /api/residences/:id/
- POST   /api/residences/
- PUT    /api/residences/:id/
- DELETE /api/residences/:id/
- Share-code + member management endpoints
- GET    /api/residences/:id/report/

Mechanical work: ~50 method signatures, ~80 handler call sites,
~25 test call sites updated. Internal sendTaskCompletedNotification
helper also takes ctx so background notification SQL nests correctly.

The remaining services (ContractorService, DocumentService,
AuthService, NotificationService, SubscriptionService) follow the same
pattern; they continue to emit untraced SQL until migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:04:01 -05:00

547 lines
15 KiB
Go

package handlers
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"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(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/
// Supports both JSON and multipart form data (for image uploads)
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
contentType := c.Request().Header.Get("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
return apperrors.BadRequest("error.failed_to_parse_form")
}
// Parse task_id (required)
taskIDStr := c.FormValue("task_id")
if taskIDStr == "" {
return apperrors.BadRequest("error.task_id_required")
}
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
if err != nil {
return apperrors.BadRequest("error.invalid_task_id_value")
}
req.TaskID = uint(taskID)
// Parse notes (optional)
req.Notes = c.FormValue("notes")
// Parse actual_cost (optional)
if costStr := c.FormValue("actual_cost"); costStr != "" {
cost, err := decimal.NewFromString(costStr)
if err == nil {
req.ActualCost = &cost
}
}
// Parse completed_at (optional)
if completedAtStr := c.FormValue("completed_at"); completedAtStr != "" {
if t, err := time.Parse(time.RFC3339, completedAtStr); err == nil {
req.CompletedAt = &t
}
}
// Handle multiple image uploads from various field names
if h.storageService != nil && c.Request().MultipartForm != nil {
for _, fieldName := range []string{"images", "image", "photo", "files"} {
files := c.Request().MultipartForm.File[fieldName]
for _, file := range files {
result, err := h.storageService.Upload(c.Request().Context(), file, "completions")
if err != nil {
return apperrors.BadRequest("error.failed_to_upload_image")
}
req.ImageURLs = append(req.ImageURLs, result.URL)
}
}
}
} else {
// Standard JSON request
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)
}