bc3da007db
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod, overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api and honeydue-worker. otelecho.Middleware opens a span per HTTP request. Step 2 — Manual spans: storage_service.Upload now takes ctx and emits storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket, result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token spans with topic, status_code, reason. Asynq middleware emits asynq.handle:<task_type> per job with retry/payload attrs and records asynq_job_duration_seconds. Step 3 — Database: otelgorm plugin registered in database.Connect, so any SQL emitted via db.WithContext(ctx) attaches to the request span. Every repository now exposes WithContext(ctx) *XRepository as the migration helper. TaskService.ListTasks and GetTasksByResidence are migrated end-to-end (ctx threaded through handler → service → repo); remaining services adopt the same pattern incrementally — pre-migration methods still emit untraced SQL via the unchanged db field. OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env → honeydue-secrets → api+worker Deployments via secretKeyRef (optional). 02-setup-secrets.sh sources them from prod.env on next run; manifests mark both env vars optional so the deployment rolls without traces if the secret is absent. ch15 observability doc now lists what produces spans today vs the remaining migration work, with the explicit per-method pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
547 lines
15 KiB
Go
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(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(&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(&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(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(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(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(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(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(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(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(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(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(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(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(&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(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(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()
|
|
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()
|
|
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()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, frequencies)
|
|
}
|