Files
honeyDueAPI/internal/handlers/static_data_handler.go
T
Trey t e881d37de0
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 Auth/Contractor/Document/Notification/Subscription services to ctx
Every public method on these five services now takes ctx context.Context as
the first arg and routes its repo calls through .WithContext(ctx). With
TaskService and ResidenceService already migrated, this means every
in-process service that touches Postgres now produces a flame graph in
Jaeger where the SQL spans nest under the parent HTTP request span.

Endpoints now fully traced (HTTP → service → SQL):
- /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification
- /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile
- /api/contractors/* (CRUD + favorite + by-residence + tasks)
- /api/documents/* (CRUD + activate/deactivate + image upload/delete)
- /api/notifications/* (list, count, mark-read, prefs, devices)
- /api/subscription/* (status, purchase, cancel, triggers, promotions)
- All previously-migrated /api/tasks/* and /api/residences/* paths

Internal helpers also threaded:
- TaskService.sendTaskCompletedNotification → forwards ctx
- TaskService.UpdateUserTimezone → forwards ctx to NotificationService
- ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit
- NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx

~75 method signatures, ~120 handler/test call sites updated. Tests pass
green; the only failure is the pre-existing flaky TaskHandler_QuickComplete
SQLite race that fails ~60% of runs on master.

Step 3 of the observability plan is now genuinely complete: every API
endpoint backed by a Go service emits a per-request flame graph with
HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable.

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

152 lines
4.8 KiB
Go

package handlers
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/services"
)
// SeededDataResponse represents the unified seeded data response
type SeededDataResponse struct {
ResidenceTypes interface{} `json:"residence_types"`
TaskCategories interface{} `json:"task_categories"`
TaskPriorities interface{} `json:"task_priorities"`
TaskFrequencies interface{} `json:"task_frequencies"`
ContractorSpecialties interface{} `json:"contractor_specialties"`
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"`
}
// StaticDataHandler handles static/lookup data endpoints
type StaticDataHandler struct {
residenceService *services.ResidenceService
taskService *services.TaskService
contractorService *services.ContractorService
taskTemplateService *services.TaskTemplateService
cache *services.CacheService
}
// NewStaticDataHandler creates a new static data handler
func NewStaticDataHandler(
residenceService *services.ResidenceService,
taskService *services.TaskService,
contractorService *services.ContractorService,
taskTemplateService *services.TaskTemplateService,
cache *services.CacheService,
) *StaticDataHandler {
return &StaticDataHandler{
residenceService: residenceService,
taskService: taskService,
contractorService: contractorService,
taskTemplateService: taskTemplateService,
cache: cache,
}
}
// GetStaticData handles GET /api/static_data/
// Returns all lookup/reference data in a single response with ETag support
func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
ctx := c.Request().Context()
// Check If-None-Match header for conditional request
// Strip W/ prefix if present (added by reverse proxy, but we store without it)
clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/")
// Try to get cached ETag first (fast path for 304 responses)
if h.cache != nil && clientETag != "" {
cachedETag, err := h.cache.GetSeededDataETag(ctx)
if err == nil && cachedETag == clientETag {
// Client has the latest data, return 304 Not Modified
return c.NoContent(http.StatusNotModified)
}
}
// Try to get cached seeded data
if h.cache != nil {
var cachedData SeededDataResponse
err := h.cache.GetCachedSeededData(ctx, &cachedData)
if err == nil {
// Cache hit - get the ETag and return data
etag, etagErr := h.cache.GetSeededDataETag(ctx)
if etagErr == nil {
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
}
return c.JSON(http.StatusOK, cachedData)
} else if err != redis.Nil {
// Log cache error but continue to fetch from DB
log.Warn().Err(err).Msg("Failed to get cached seeded data")
}
}
// Cache miss - fetch all data from services
residenceTypes, err := h.residenceService.GetResidenceTypes(c.Request().Context())
if err != nil {
return err
}
taskCategories, err := h.taskService.GetCategories(c.Request().Context())
if err != nil {
return err
}
taskPriorities, err := h.taskService.GetPriorities(c.Request().Context())
if err != nil {
return err
}
taskFrequencies, err := h.taskService.GetFrequencies(c.Request().Context())
if err != nil {
return err
}
contractorSpecialties, err := h.contractorService.GetSpecialties(c.Request().Context())
if err != nil {
return err
}
taskTemplates, err := h.taskTemplateService.GetGrouped()
if err != nil {
return err
}
// Build response
seededData := SeededDataResponse{
ResidenceTypes: residenceTypes,
TaskCategories: taskCategories,
TaskPriorities: taskPriorities,
TaskFrequencies: taskFrequencies,
ContractorSpecialties: contractorSpecialties,
TaskTemplates: taskTemplates,
}
// Cache the data and get ETag
if h.cache != nil {
etag, cacheErr := h.cache.CacheSeededData(ctx, seededData)
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache seeded data")
} else {
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
}
}
return c.JSON(http.StatusOK, seededData)
}
// RefreshStaticData handles POST /api/static_data/refresh/
// This is a no-op since data is fetched fresh each time
// Kept for API compatibility with mobile clients
func (h *StaticDataHandler) RefreshStaticData(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"message": i18n.LocalizedMessage(c, "message.static_data_refreshed"),
"status": "success",
})
}