Files
honeyDueAPI/internal/handlers/static_data_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

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()
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",
})
}