Add Redis caching for lookup data and admin cache management

- Add lookup-specific cache keys and methods to CacheService
- Add cache refresh on lookup CRUD operations in AdminLookupHandler
- Add Redis caching after seed-lookups in AdminSettingsHandler
- Add ETag generation for seeded data to support client-side caching
- Update task template handler with cache invalidation
- Fix route for clear-cache endpoint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-05 22:35:09 -06:00
parent 1b06c0639c
commit 91a1f7ebed
8 changed files with 856 additions and 100 deletions

View File

@@ -1,14 +1,17 @@
package handlers
import (
"context"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
)
// AdminLookupHandler handles admin lookup table management endpoints
@@ -21,6 +24,159 @@ func NewAdminLookupHandler(db *gorm.DB) *AdminLookupHandler {
return &AdminLookupHandler{db: db}
}
// refreshCategoriesCache invalidates and refreshes the categories cache
func (h *AdminLookupHandler) refreshCategoriesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var categories []models.TaskCategory
if err := h.db.Order("display_order ASC, name ASC").Find(&categories).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch categories for cache refresh")
return
}
if err := cache.CacheCategories(ctx, categories); err != nil {
log.Warn().Err(err).Msg("Failed to cache categories")
return
}
log.Debug().Int("count", len(categories)).Msg("Refreshed categories cache")
// Invalidate unified seeded data cache
h.invalidateSeededDataCache(ctx)
}
// refreshPrioritiesCache invalidates and refreshes the priorities cache
func (h *AdminLookupHandler) refreshPrioritiesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var priorities []models.TaskPriority
if err := h.db.Order("display_order ASC, level ASC").Find(&priorities).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch priorities for cache refresh")
return
}
if err := cache.CachePriorities(ctx, priorities); err != nil {
log.Warn().Err(err).Msg("Failed to cache priorities")
return
}
log.Debug().Int("count", len(priorities)).Msg("Refreshed priorities cache")
// Invalidate unified seeded data cache
h.invalidateSeededDataCache(ctx)
}
// refreshStatusesCache invalidates and refreshes the statuses cache
func (h *AdminLookupHandler) refreshStatusesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var statuses []models.TaskStatus
if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch statuses for cache refresh")
return
}
if err := cache.CacheStatuses(ctx, statuses); err != nil {
log.Warn().Err(err).Msg("Failed to cache statuses")
return
}
log.Debug().Int("count", len(statuses)).Msg("Refreshed statuses cache")
// Invalidate unified seeded data cache
h.invalidateSeededDataCache(ctx)
}
// refreshFrequenciesCache invalidates and refreshes the frequencies cache
func (h *AdminLookupHandler) refreshFrequenciesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var frequencies []models.TaskFrequency
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch frequencies for cache refresh")
return
}
if err := cache.CacheFrequencies(ctx, frequencies); err != nil {
log.Warn().Err(err).Msg("Failed to cache frequencies")
return
}
log.Debug().Int("count", len(frequencies)).Msg("Refreshed frequencies cache")
// Invalidate unified seeded data cache
h.invalidateSeededDataCache(ctx)
}
// refreshResidenceTypesCache invalidates and refreshes the residence types cache
func (h *AdminLookupHandler) refreshResidenceTypesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var types []models.ResidenceType
if err := h.db.Order("name ASC").Find(&types).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch residence types for cache refresh")
return
}
if err := cache.CacheResidenceTypes(ctx, types); err != nil {
log.Warn().Err(err).Msg("Failed to cache residence types")
return
}
log.Debug().Int("count", len(types)).Msg("Refreshed residence types cache")
// Invalidate unified seeded data cache
h.invalidateSeededDataCache(ctx)
}
// refreshSpecialtiesCache invalidates and refreshes the specialties cache
func (h *AdminLookupHandler) refreshSpecialtiesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var specialties []models.ContractorSpecialty
if err := h.db.Order("display_order ASC, name ASC").Find(&specialties).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch specialties for cache refresh")
return
}
if err := cache.CacheSpecialties(ctx, specialties); err != nil {
log.Warn().Err(err).Msg("Failed to cache specialties")
return
}
log.Debug().Int("count", len(specialties)).Msg("Refreshed specialties cache")
// Invalidate unified seeded data cache
h.invalidateSeededDataCache(ctx)
}
// invalidateSeededDataCache invalidates the unified seeded data cache
// This should be called whenever any lookup data changes
func (h *AdminLookupHandler) invalidateSeededDataCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
if err := cache.InvalidateSeededData(ctx); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate seeded data cache")
return
}
log.Debug().Msg("Invalidated seeded data cache")
}
// ========== Task Categories ==========
type TaskCategoryResponse struct {
@@ -84,6 +240,9 @@ func (h *AdminLookupHandler) CreateCategory(c *gin.Context) {
return
}
// Refresh cache after creating
h.refreshCategoriesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskCategoryResponse{
ID: category.ID,
Name: category.Name,
@@ -130,6 +289,9 @@ func (h *AdminLookupHandler) UpdateCategory(c *gin.Context) {
return
}
// Refresh cache after updating
h.refreshCategoriesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskCategoryResponse{
ID: category.ID,
Name: category.Name,
@@ -160,6 +322,9 @@ func (h *AdminLookupHandler) DeleteCategory(c *gin.Context) {
return
}
// Refresh cache after deleting
h.refreshCategoriesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Category deleted successfully"})
}
@@ -222,6 +387,9 @@ func (h *AdminLookupHandler) CreatePriority(c *gin.Context) {
return
}
// Refresh cache after creating
h.refreshPrioritiesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskPriorityResponse{
ID: priority.ID,
Name: priority.Name,
@@ -266,6 +434,9 @@ func (h *AdminLookupHandler) UpdatePriority(c *gin.Context) {
return
}
// Refresh cache after updating
h.refreshPrioritiesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskPriorityResponse{
ID: priority.ID,
Name: priority.Name,
@@ -294,6 +465,9 @@ func (h *AdminLookupHandler) DeletePriority(c *gin.Context) {
return
}
// Refresh cache after deleting
h.refreshPrioritiesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"})
}
@@ -356,6 +530,9 @@ func (h *AdminLookupHandler) CreateStatus(c *gin.Context) {
return
}
// Refresh cache after creating
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskStatusResponse{
ID: status.ID,
Name: status.Name,
@@ -400,6 +577,9 @@ func (h *AdminLookupHandler) UpdateStatus(c *gin.Context) {
return
}
// Refresh cache after updating
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskStatusResponse{
ID: status.ID,
Name: status.Name,
@@ -428,6 +608,9 @@ func (h *AdminLookupHandler) DeleteStatus(c *gin.Context) {
return
}
// Refresh cache after deleting
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Status deleted successfully"})
}
@@ -486,6 +669,9 @@ func (h *AdminLookupHandler) CreateFrequency(c *gin.Context) {
return
}
// Refresh cache after creating
h.refreshFrequenciesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskFrequencyResponse{
ID: frequency.ID,
Name: frequency.Name,
@@ -528,6 +714,9 @@ func (h *AdminLookupHandler) UpdateFrequency(c *gin.Context) {
return
}
// Refresh cache after updating
h.refreshFrequenciesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskFrequencyResponse{
ID: frequency.ID,
Name: frequency.Name,
@@ -555,6 +744,9 @@ func (h *AdminLookupHandler) DeleteFrequency(c *gin.Context) {
return
}
// Refresh cache after deleting
h.refreshFrequenciesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Frequency deleted successfully"})
}
@@ -600,6 +792,9 @@ func (h *AdminLookupHandler) CreateResidenceType(c *gin.Context) {
return
}
// Refresh cache after creating
h.refreshResidenceTypesCache(c.Request.Context())
c.JSON(http.StatusCreated, ResidenceTypeResponse{
ID: residenceType.ID,
Name: residenceType.Name,
@@ -635,6 +830,9 @@ func (h *AdminLookupHandler) UpdateResidenceType(c *gin.Context) {
return
}
// Refresh cache after updating
h.refreshResidenceTypesCache(c.Request.Context())
c.JSON(http.StatusOK, ResidenceTypeResponse{
ID: residenceType.ID,
Name: residenceType.Name,
@@ -660,6 +858,9 @@ func (h *AdminLookupHandler) DeleteResidenceType(c *gin.Context) {
return
}
// Refresh cache after deleting
h.refreshResidenceTypesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Residence type deleted successfully"})
}
@@ -722,6 +923,9 @@ func (h *AdminLookupHandler) CreateSpecialty(c *gin.Context) {
return
}
// Refresh cache after creating
h.refreshSpecialtiesCache(c.Request.Context())
c.JSON(http.StatusCreated, ContractorSpecialtyResponse{
ID: specialty.ID,
Name: specialty.Name,
@@ -766,6 +970,9 @@ func (h *AdminLookupHandler) UpdateSpecialty(c *gin.Context) {
return
}
// Refresh cache after updating
h.refreshSpecialtiesCache(c.Request.Context())
c.JSON(http.StatusOK, ContractorSpecialtyResponse{
ID: specialty.ID,
Name: specialty.Name,
@@ -795,6 +1002,9 @@ func (h *AdminLookupHandler) DeleteSpecialty(c *gin.Context) {
return
}
// Refresh cache after deleting
h.refreshSpecialtiesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Specialty deleted successfully"})
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"fmt"
"net/http"
"os"
@@ -8,9 +9,11 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
)
// AdminSettingsHandler handles system settings management
@@ -85,7 +88,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
}
// SeedLookups handles POST /api/admin/settings/seed-lookups
// Seeds both lookup tables AND task templates
// Seeds both lookup tables AND task templates, then caches all lookups in Redis
func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
// First seed lookup tables
if err := h.runSeedFile("001_lookups.sql"); err != nil {
@@ -99,7 +102,230 @@ func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"message": "Lookup data and task templates seeded successfully"})
// Cache all lookups in Redis
cached, cacheErr := h.cacheAllLookups(c.Request.Context())
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache lookups in Redis, but seed was successful")
}
response := gin.H{
"message": "Lookup data and task templates seeded successfully",
"redis_cached": cached,
}
c.JSON(http.StatusOK, response)
}
// cacheAllLookups fetches all lookup data from the database and caches it in Redis
func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error) {
cache := services.GetCache()
if cache == nil {
return false, fmt.Errorf("Redis cache not available")
}
// Fetch and cache task categories
var categories []models.TaskCategory
if err := h.db.Order("display_order ASC, name ASC").Find(&categories).Error; err != nil {
return false, fmt.Errorf("failed to fetch categories: %w", err)
}
if err := cache.CacheCategories(ctx, categories); err != nil {
return false, fmt.Errorf("failed to cache categories: %w", err)
}
log.Debug().Int("count", len(categories)).Msg("Cached task categories")
// Fetch and cache task priorities
var priorities []models.TaskPriority
if err := h.db.Order("display_order ASC, level ASC").Find(&priorities).Error; err != nil {
return false, fmt.Errorf("failed to fetch priorities: %w", err)
}
if err := cache.CachePriorities(ctx, priorities); err != nil {
return false, fmt.Errorf("failed to cache priorities: %w", err)
}
log.Debug().Int("count", len(priorities)).Msg("Cached task priorities")
// Fetch and cache task statuses
var statuses []models.TaskStatus
if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil {
return false, fmt.Errorf("failed to fetch statuses: %w", err)
}
if err := cache.CacheStatuses(ctx, statuses); err != nil {
return false, fmt.Errorf("failed to cache statuses: %w", err)
}
log.Debug().Int("count", len(statuses)).Msg("Cached task statuses")
// Fetch and cache task frequencies
var frequencies []models.TaskFrequency
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
return false, fmt.Errorf("failed to fetch frequencies: %w", err)
}
if err := cache.CacheFrequencies(ctx, frequencies); err != nil {
return false, fmt.Errorf("failed to cache frequencies: %w", err)
}
log.Debug().Int("count", len(frequencies)).Msg("Cached task frequencies")
// Fetch and cache residence types
var residenceTypes []models.ResidenceType
if err := h.db.Order("name ASC").Find(&residenceTypes).Error; err != nil {
return false, fmt.Errorf("failed to fetch residence types: %w", err)
}
if err := cache.CacheResidenceTypes(ctx, residenceTypes); err != nil {
return false, fmt.Errorf("failed to cache residence types: %w", err)
}
log.Debug().Int("count", len(residenceTypes)).Msg("Cached residence types")
// Fetch and cache contractor specialties
var specialties []models.ContractorSpecialty
if err := h.db.Order("display_order ASC, name ASC").Find(&specialties).Error; err != nil {
return false, fmt.Errorf("failed to fetch specialties: %w", err)
}
if err := cache.CacheSpecialties(ctx, specialties); err != nil {
return false, fmt.Errorf("failed to cache specialties: %w", err)
}
log.Debug().Int("count", len(specialties)).Msg("Cached contractor specialties")
// Fetch and cache task templates (only active ones)
var taskTemplates []models.TaskTemplate
if err := h.db.Preload("Category").Preload("Frequency").
Where("is_active = ?", true).
Order("display_order ASC, title ASC").
Find(&taskTemplates).Error; err != nil {
return false, fmt.Errorf("failed to fetch task templates: %w", err)
}
if err := cache.CacheTaskTemplates(ctx, taskTemplates); err != nil {
return false, fmt.Errorf("failed to cache task templates: %w", err)
}
log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates")
// Build and cache the unified seeded data response
// Import the grouped response type
seededData := map[string]interface{}{
"residence_types": residenceTypes,
"task_categories": categories,
"task_priorities": priorities,
"task_frequencies": frequencies,
"task_statuses": statuses,
"contractor_specialties": specialties,
"task_templates": buildGroupedTemplates(taskTemplates),
}
etag, err := cache.CacheSeededData(ctx, seededData)
if err != nil {
return false, fmt.Errorf("failed to cache seeded data: %w", err)
}
log.Debug().Str("etag", etag).Msg("Cached unified seeded data")
log.Info().Msg("All lookup data cached in Redis successfully")
return true, nil
}
// buildGroupedTemplates groups task templates by category for the seeded data response
func buildGroupedTemplates(templates []models.TaskTemplate) map[string]interface{} {
type templateResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category map[string]interface{} `json:"category,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency map[string]interface{} `json:"frequency,omitempty"`
IconIOS string `json:"icon_ios"`
IconAndroid string `json:"icon_android"`
Tags []string `json:"tags"`
DisplayOrder int `json:"display_order"`
IsActive bool `json:"is_active"`
}
type categoryGroup struct {
CategoryName string `json:"category_name"`
CategoryID *uint `json:"category_id"`
Templates []templateResponse `json:"templates"`
Count int `json:"count"`
}
categoryMap := make(map[string]*categoryGroup)
categoryOrder := []string{}
for _, t := range templates {
categoryName := "Uncategorized"
var categoryID *uint
if t.Category != nil {
categoryName = t.Category.Name
categoryID = &t.Category.ID
}
if _, exists := categoryMap[categoryName]; !exists {
categoryMap[categoryName] = &categoryGroup{
CategoryName: categoryName,
CategoryID: categoryID,
Templates: []templateResponse{},
}
categoryOrder = append(categoryOrder, categoryName)
}
resp := templateResponse{
ID: t.ID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
FrequencyID: t.FrequencyID,
IconIOS: t.IconIOS,
IconAndroid: t.IconAndroid,
Tags: parseTags(t.Tags),
DisplayOrder: t.DisplayOrder,
IsActive: t.IsActive,
}
if t.Category != nil {
resp.Category = map[string]interface{}{
"id": t.Category.ID,
"name": t.Category.Name,
"description": t.Category.Description,
"icon": t.Category.Icon,
"color": t.Category.Color,
"display_order": t.Category.DisplayOrder,
}
}
if t.Frequency != nil {
resp.Frequency = map[string]interface{}{
"id": t.Frequency.ID,
"name": t.Frequency.Name,
"days": t.Frequency.Days,
"display_order": t.Frequency.DisplayOrder,
}
}
categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, resp)
}
categories := make([]categoryGroup, len(categoryOrder))
totalCount := 0
for i, name := range categoryOrder {
group := categoryMap[name]
group.Count = len(group.Templates)
totalCount += group.Count
categories[i] = *group
}
return map[string]interface{}{
"categories": categories,
"total_count": totalCount,
}
}
// parseTags splits a comma-separated tags string into a slice
func parseTags(tags string) []string {
if tags == "" {
return []string{}
}
parts := strings.Split(tags, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// SeedTestData handles POST /api/admin/settings/seed-test-data

View File

@@ -1,14 +1,17 @@
package handlers
import (
"context"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
)
// AdminTaskTemplateHandler handles admin task template management endpoints
@@ -21,6 +24,36 @@ func NewAdminTaskTemplateHandler(db *gorm.DB) *AdminTaskTemplateHandler {
return &AdminTaskTemplateHandler{db: db}
}
// refreshTaskTemplatesCache invalidates and refreshes the task templates cache
func (h *AdminTaskTemplateHandler) refreshTaskTemplatesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var templates []models.TaskTemplate
if err := h.db.Preload("Category").Preload("Frequency").
Where("is_active = ?", true).
Order("display_order ASC, title ASC").
Find(&templates).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch task templates for cache refresh")
return
}
if err := cache.CacheTaskTemplates(ctx, templates); err != nil {
log.Warn().Err(err).Msg("Failed to cache task templates")
return
}
log.Debug().Int("count", len(templates)).Msg("Refreshed task templates cache")
// Invalidate unified seeded data cache
if err := cache.InvalidateSeededData(ctx); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate seeded data cache")
return
}
log.Debug().Msg("Invalidated seeded data cache")
}
// TaskTemplateResponse represents a task template in admin responses
type TaskTemplateResponse struct {
ID uint `json:"id"`
@@ -145,6 +178,9 @@ func (h *AdminTaskTemplateHandler) CreateTemplate(c *gin.Context) {
// Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
// Refresh cache after creating
h.refreshTaskTemplatesCache(c.Request.Context())
c.JSON(http.StatusCreated, h.toResponse(&template))
}
@@ -195,6 +231,9 @@ func (h *AdminTaskTemplateHandler) UpdateTemplate(c *gin.Context) {
// Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
// Refresh cache after updating
h.refreshTaskTemplatesCache(c.Request.Context())
c.JSON(http.StatusOK, h.toResponse(&template))
}
@@ -211,6 +250,9 @@ func (h *AdminTaskTemplateHandler) DeleteTemplate(c *gin.Context) {
return
}
// Refresh cache after deleting
h.refreshTaskTemplatesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Template deleted successfully"})
}
@@ -241,6 +283,9 @@ func (h *AdminTaskTemplateHandler) ToggleActive(c *gin.Context) {
// Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
// Refresh cache after toggling active status
h.refreshTaskTemplatesCache(c.Request.Context())
c.JSON(http.StatusOK, h.toResponse(&template))
}
@@ -279,6 +324,9 @@ func (h *AdminTaskTemplateHandler) BulkCreate(c *gin.Context) {
return
}
// Refresh cache after bulk creating
h.refreshTaskTemplatesCache(c.Request.Context())
c.JSON(http.StatusCreated, gin.H{"message": "Templates created successfully", "count": len(templates)})
}