From 91a1f7ebeda1c8d88924c2d444b2a522f65486b2 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 5 Dec 2025 22:35:09 -0600 Subject: [PATCH] Add Redis caching for lookup data and admin cache management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/admin/handlers/lookup_handler.go | 210 ++++++++++++++++ internal/admin/handlers/settings_handler.go | 230 +++++++++++++++++- .../admin/handlers/task_template_handler.go | 48 ++++ internal/handlers/static_data_handler.go | 107 ++++++-- internal/router/router.go | 2 +- internal/services/cache_service.go | 197 +++++++++++++++ seeds/002_test_data.sql | 28 +-- seeds/003_task_templates.sql | 134 +++++----- 8 files changed, 856 insertions(+), 100 deletions(-) diff --git a/internal/admin/handlers/lookup_handler.go b/internal/admin/handlers/lookup_handler.go index 0e9238d..bf5d60b 100644 --- a/internal/admin/handlers/lookup_handler.go +++ b/internal/admin/handlers/lookup_handler.go @@ -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"}) } diff --git a/internal/admin/handlers/settings_handler.go b/internal/admin/handlers/settings_handler.go index 95ef579..1b6e3d5 100644 --- a/internal/admin/handlers/settings_handler.go +++ b/internal/admin/handlers/settings_handler.go @@ -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 diff --git a/internal/admin/handlers/task_template_handler.go b/internal/admin/handlers/task_template_handler.go index 8a1a55a..3435310 100644 --- a/internal/admin/handlers/task_template_handler.go +++ b/internal/admin/handlers/task_template_handler.go @@ -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)}) } diff --git a/internal/handlers/static_data_handler.go b/internal/handlers/static_data_handler.go index b3e4907..89dacf0 100644 --- a/internal/handlers/static_data_handler.go +++ b/internal/handlers/static_data_handler.go @@ -4,16 +4,32 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" + "github.com/treytartt/casera-api/internal/dto/responses" "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-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"` + TaskStatuses interface{} `json:"task_statuses"` + 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 + residenceService *services.ResidenceService + taskService *services.TaskService + contractorService *services.ContractorService + taskTemplateService *services.TaskTemplateService + cache *services.CacheService } // NewStaticDataHandler creates a new static data handler @@ -21,18 +37,56 @@ 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, + 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 +// Returns all lookup/reference data in a single response with ETag support func (h *StaticDataHandler) GetStaticData(c *gin.Context) { - // Get all lookup data + ctx := c.Request.Context() + + // Check If-None-Match header for conditional request + clientETag := c.GetHeader("If-None-Match") + + // 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 + c.Status(http.StatusNotModified) + return + } + } + + // 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.Header("ETag", etag) + c.Header("Cache-Control", "private, max-age=3600") + } + c.JSON(http.StatusOK, cachedData) + return + } 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() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_residence_types")}) @@ -69,14 +123,35 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "residence_types": residenceTypes, - "task_categories": taskCategories, - "task_priorities": taskPriorities, - "task_frequencies": taskFrequencies, - "task_statuses": taskStatuses, - "contractor_specialties": contractorSpecialties, - }) + taskTemplates, err := h.taskTemplateService.GetGrouped() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_templates")}) + return + } + + // Build response + seededData := SeededDataResponse{ + ResidenceTypes: residenceTypes, + TaskCategories: taskCategories, + TaskPriorities: taskPriorities, + TaskFrequencies: taskFrequencies, + TaskStatuses: taskStatuses, + 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.Header("ETag", etag) + c.Header("Cache-Control", "private, max-age=3600") + } + } + + c.JSON(http.StatusOK, seededData) } // RefreshStaticData handles POST /api/static_data/refresh/ diff --git a/internal/router/router.go b/internal/router/router.go index c89e99d..7f7f606 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -118,7 +118,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine { documentHandler := handlers.NewDocumentHandler(documentService, deps.StorageService) notificationHandler := handlers.NewNotificationHandler(notificationService) subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService) - staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService) + staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService, taskTemplateService, deps.Cache) taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService) // Initialize upload handler (if storage service is available) diff --git a/internal/services/cache_service.go b/internal/services/cache_service.go index 2d843e7..83afb2f 100644 --- a/internal/services/cache_service.go +++ b/internal/services/cache_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "crypto/md5" "encoding/json" "fmt" "time" @@ -161,3 +162,199 @@ func (c *CacheService) GetCachedStaticData(ctx context.Context, dest interface{} func (c *CacheService) InvalidateStaticData(ctx context.Context) error { return c.Delete(ctx, StaticDataKey) } + +// Lookup data cache helpers - each lookup type gets its own key +const ( + LookupKeyPrefix = "lookup:" + LookupCategoriesKey = LookupKeyPrefix + "categories" + LookupPrioritiesKey = LookupKeyPrefix + "priorities" + LookupStatusesKey = LookupKeyPrefix + "statuses" + LookupFrequenciesKey = LookupKeyPrefix + "frequencies" + LookupResidenceTypesKey = LookupKeyPrefix + "residence_types" + LookupSpecialtiesKey = LookupKeyPrefix + "specialties" + LookupTaskTemplatesKey = LookupKeyPrefix + "task_templates" + LookupDataTTL = 24 * time.Hour // Lookup data rarely changes +) + +// CacheLookupData caches data for a specific lookup type +func (c *CacheService) CacheLookupData(ctx context.Context, key string, data interface{}) error { + return c.Set(ctx, key, data, LookupDataTTL) +} + +// GetCachedLookupData retrieves cached lookup data for a specific key +func (c *CacheService) GetCachedLookupData(ctx context.Context, key string, dest interface{}) error { + return c.Get(ctx, key, dest) +} + +// InvalidateLookupData removes cached data for a specific lookup type +func (c *CacheService) InvalidateLookupData(ctx context.Context, key string) error { + return c.Delete(ctx, key) +} + +// InvalidateAllLookups removes all cached lookup data +func (c *CacheService) InvalidateAllLookups(ctx context.Context) error { + keys := []string{ + LookupCategoriesKey, + LookupPrioritiesKey, + LookupStatusesKey, + LookupFrequenciesKey, + LookupResidenceTypesKey, + LookupSpecialtiesKey, + LookupTaskTemplatesKey, + StaticDataKey, // Also invalidate the combined static data + SeededDataKey, // Invalidate unified seeded data + SeededDataETagKey, // Invalidate seeded data ETag + } + return c.Delete(ctx, keys...) +} + +// CacheCategories caches task categories +func (c *CacheService) CacheCategories(ctx context.Context, data interface{}) error { + return c.CacheLookupData(ctx, LookupCategoriesKey, data) +} + +// GetCachedCategories retrieves cached task categories +func (c *CacheService) GetCachedCategories(ctx context.Context, dest interface{}) error { + return c.GetCachedLookupData(ctx, LookupCategoriesKey, dest) +} + +// InvalidateCategories removes cached task categories +func (c *CacheService) InvalidateCategories(ctx context.Context) error { + // Invalidate both specific key and combined static data + return c.Delete(ctx, LookupCategoriesKey, StaticDataKey) +} + +// CachePriorities caches task priorities +func (c *CacheService) CachePriorities(ctx context.Context, data interface{}) error { + return c.CacheLookupData(ctx, LookupPrioritiesKey, data) +} + +// GetCachedPriorities retrieves cached task priorities +func (c *CacheService) GetCachedPriorities(ctx context.Context, dest interface{}) error { + return c.GetCachedLookupData(ctx, LookupPrioritiesKey, dest) +} + +// InvalidatePriorities removes cached task priorities +func (c *CacheService) InvalidatePriorities(ctx context.Context) error { + return c.Delete(ctx, LookupPrioritiesKey, StaticDataKey) +} + +// CacheStatuses caches task statuses +func (c *CacheService) CacheStatuses(ctx context.Context, data interface{}) error { + return c.CacheLookupData(ctx, LookupStatusesKey, data) +} + +// GetCachedStatuses retrieves cached task statuses +func (c *CacheService) GetCachedStatuses(ctx context.Context, dest interface{}) error { + return c.GetCachedLookupData(ctx, LookupStatusesKey, dest) +} + +// InvalidateStatuses removes cached task statuses +func (c *CacheService) InvalidateStatuses(ctx context.Context) error { + return c.Delete(ctx, LookupStatusesKey, StaticDataKey) +} + +// CacheFrequencies caches task frequencies +func (c *CacheService) CacheFrequencies(ctx context.Context, data interface{}) error { + return c.CacheLookupData(ctx, LookupFrequenciesKey, data) +} + +// GetCachedFrequencies retrieves cached task frequencies +func (c *CacheService) GetCachedFrequencies(ctx context.Context, dest interface{}) error { + return c.GetCachedLookupData(ctx, LookupFrequenciesKey, dest) +} + +// InvalidateFrequencies removes cached task frequencies +func (c *CacheService) InvalidateFrequencies(ctx context.Context) error { + return c.Delete(ctx, LookupFrequenciesKey, StaticDataKey) +} + +// CacheResidenceTypes caches residence types +func (c *CacheService) CacheResidenceTypes(ctx context.Context, data interface{}) error { + return c.CacheLookupData(ctx, LookupResidenceTypesKey, data) +} + +// GetCachedResidenceTypes retrieves cached residence types +func (c *CacheService) GetCachedResidenceTypes(ctx context.Context, dest interface{}) error { + return c.GetCachedLookupData(ctx, LookupResidenceTypesKey, dest) +} + +// InvalidateResidenceTypes removes cached residence types +func (c *CacheService) InvalidateResidenceTypes(ctx context.Context) error { + return c.Delete(ctx, LookupResidenceTypesKey, StaticDataKey) +} + +// CacheSpecialties caches contractor specialties +func (c *CacheService) CacheSpecialties(ctx context.Context, data interface{}) error { + return c.CacheLookupData(ctx, LookupSpecialtiesKey, data) +} + +// GetCachedSpecialties retrieves cached contractor specialties +func (c *CacheService) GetCachedSpecialties(ctx context.Context, dest interface{}) error { + return c.GetCachedLookupData(ctx, LookupSpecialtiesKey, dest) +} + +// InvalidateSpecialties removes cached contractor specialties +func (c *CacheService) InvalidateSpecialties(ctx context.Context) error { + return c.Delete(ctx, LookupSpecialtiesKey, StaticDataKey) +} + +// CacheTaskTemplates caches task templates +func (c *CacheService) CacheTaskTemplates(ctx context.Context, data interface{}) error { + return c.CacheLookupData(ctx, LookupTaskTemplatesKey, data) +} + +// GetCachedTaskTemplates retrieves cached task templates +func (c *CacheService) GetCachedTaskTemplates(ctx context.Context, dest interface{}) error { + return c.GetCachedLookupData(ctx, LookupTaskTemplatesKey, dest) +} + +// InvalidateTaskTemplates removes cached task templates +func (c *CacheService) InvalidateTaskTemplates(ctx context.Context) error { + return c.Delete(ctx, LookupTaskTemplatesKey, StaticDataKey) +} + +// Unified seeded data cache helpers +const ( + SeededDataKey = "seeded_data" + SeededDataETagKey = "seeded_data:etag" + SeededDataTTL = 24 * time.Hour +) + +// CacheSeededData caches the unified seeded data and generates an ETag +func (c *CacheService) CacheSeededData(ctx context.Context, data interface{}) (string, error) { + jsonData, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal seeded data: %w", err) + } + + // Generate MD5 ETag from the JSON data + hash := md5.Sum(jsonData) + etag := fmt.Sprintf("\"%x\"", hash) + + // Store both the data and the ETag + if err := c.client.Set(ctx, SeededDataKey, jsonData, SeededDataTTL).Err(); err != nil { + return "", fmt.Errorf("failed to cache seeded data: %w", err) + } + + if err := c.client.Set(ctx, SeededDataETagKey, etag, SeededDataTTL).Err(); err != nil { + return "", fmt.Errorf("failed to cache seeded data etag: %w", err) + } + + return etag, nil +} + +// GetCachedSeededData retrieves cached unified seeded data +func (c *CacheService) GetCachedSeededData(ctx context.Context, dest interface{}) error { + return c.Get(ctx, SeededDataKey, dest) +} + +// GetSeededDataETag retrieves the cached ETag for seeded data +func (c *CacheService) GetSeededDataETag(ctx context.Context) (string, error) { + return c.GetString(ctx, SeededDataETagKey) +} + +// InvalidateSeededData removes cached seeded data and its ETag +func (c *CacheService) InvalidateSeededData(ctx context.Context) error { + return c.Delete(ctx, SeededDataKey, SeededDataETagKey) +} diff --git a/seeds/002_test_data.sql b/seeds/002_test_data.sql index a5d96d7..1a347b6 100644 --- a/seeds/002_test_data.sql +++ b/seeds/002_test_data.sql @@ -3,23 +3,23 @@ -- Note: Run seed-lookups first to populate lookup tables -- ===================================================== --- TEST USERS (password is 'password123' for all users) --- bcrypt hash: $2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi +-- TEST USERS (password is 'test1234' for all users) +-- bcrypt hash: $2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK -- ===================================================== INSERT INTO auth_user (id, username, password, email, first_name, last_name, is_active, is_staff, is_superuser, date_joined, last_login) VALUES - (1, 'admin', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'admin@mycrib.com', 'Admin', 'User', true, true, true, NOW() - INTERVAL '1 year', NOW() - INTERVAL '1 hour'), - (2, 'john.doe', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john.doe@example.com', 'John', 'Doe', true, false, false, NOW() - INTERVAL '6 months', NOW() - INTERVAL '2 hours'), - (3, 'jane.smith', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'jane.smith@example.com', 'Jane', 'Smith', true, false, false, NOW() - INTERVAL '5 months', NOW() - INTERVAL '1 day'), - (4, 'bob.wilson', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'bob.wilson@example.com', 'Bob', 'Wilson', true, false, false, NOW() - INTERVAL '4 months', NOW() - INTERVAL '3 days'), - (5, 'alice.johnson', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'alice.johnson@example.com', 'Alice', 'Johnson', true, false, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '1 week'), - (6, 'charlie.brown', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'charlie.brown@example.com', 'Charlie', 'Brown', true, false, false, NOW() - INTERVAL '2 months', NOW() - INTERVAL '2 weeks'), - (7, 'diana.ross', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'diana.ross@example.com', 'Diana', 'Ross', true, false, false, NOW() - INTERVAL '1 month', NULL), - (8, 'edward.norton', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'edward.norton@example.com', 'Edward', 'Norton', true, false, false, NOW() - INTERVAL '2 weeks', NOW() - INTERVAL '5 days'), - (9, 'fiona.apple', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'fiona.apple@example.com', 'Fiona', 'Apple', true, false, false, NOW() - INTERVAL '1 week', NOW()), - (10, 'inactive.user', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'inactive@example.com', 'Inactive', 'User', false, false, false, NOW() - INTERVAL '1 year', NULL), - (11, 'staff.member', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'staff@mycrib.com', 'Staff', 'Member', true, true, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '6 hours'), - (12, 'george.harrison', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'george@example.com', 'George', 'Harrison', true, false, false, NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days') + (1, 'admin', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'admin@mycrib.com', 'Admin', 'User', true, true, true, NOW() - INTERVAL '1 year', NOW() - INTERVAL '1 hour'), + (2, 'john.doe', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'john.doe@example.com', 'John', 'Doe', true, false, false, NOW() - INTERVAL '6 months', NOW() - INTERVAL '2 hours'), + (3, 'jane.smith', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'jane.smith@example.com', 'Jane', 'Smith', true, false, false, NOW() - INTERVAL '5 months', NOW() - INTERVAL '1 day'), + (4, 'bob.wilson', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'bob.wilson@example.com', 'Bob', 'Wilson', true, false, false, NOW() - INTERVAL '4 months', NOW() - INTERVAL '3 days'), + (5, 'alice.johnson', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'alice.johnson@example.com', 'Alice', 'Johnson', true, false, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '1 week'), + (6, 'charlie.brown', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'charlie.brown@example.com', 'Charlie', 'Brown', true, false, false, NOW() - INTERVAL '2 months', NOW() - INTERVAL '2 weeks'), + (7, 'diana.ross', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'diana.ross@example.com', 'Diana', 'Ross', true, false, false, NOW() - INTERVAL '1 month', NULL), + (8, 'edward.norton', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'edward.norton@example.com', 'Edward', 'Norton', true, false, false, NOW() - INTERVAL '2 weeks', NOW() - INTERVAL '5 days'), + (9, 'fiona.apple', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'fiona.apple@example.com', 'Fiona', 'Apple', true, false, false, NOW() - INTERVAL '1 week', NOW()), + (10, 'inactive.user', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'inactive@example.com', 'Inactive', 'User', false, false, false, NOW() - INTERVAL '1 year', NULL), + (11, 'staff.member', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'staff@mycrib.com', 'Staff', 'Member', true, true, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '6 hours'), + (12, 'george.harrison', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', 'george@example.com', 'George', 'Harrison', true, false, false, NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days') ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, password = EXCLUDED.password, email = EXCLUDED.email, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, is_active = EXCLUDED.is_active, diff --git a/seeds/003_task_templates.sql b/seeds/003_task_templates.sql index 8fd2b54..97b35c3 100644 --- a/seeds/003_task_templates.sql +++ b/seeds/003_task_templates.sql @@ -9,86 +9,86 @@ -- 1 = Once, 2 = Daily, 3 = Weekly, 4 = Bi-Weekly, 5 = Monthly -- 6 = Quarterly, 7 = Semi-Annually, 8 = Annually --- Task Templates +-- Task Templates from adam_task list INSERT INTO task_tasktemplate (id, created_at, updated_at, title, description, category_id, frequency_id, icon_ios, icon_android, tags, display_order, is_active) VALUES -- PLUMBING (category_id = 9) (1, NOW(), NOW(), 'Check/Replace Water Heater Anode Rod', 'Inspect anode rod for corrosion and replace if more than 50% depleted', 9, 8, 'wrench.and.screwdriver.fill', 'Build', 'water heater,anode,corrosion,tank', 1, true), (2, NOW(), NOW(), 'Test Interior Water Shutoffs', 'Turn each shutoff valve to ensure it works properly', 9, 8, 'spigot.fill', 'Water', 'shutoff,valve,water,emergency', 2, true), - (3, NOW(), NOW(), 'Test Water Meter Shutoff', 'Ensure main water shutoff at meter is functional', 9, 8, 'gauge.with.dots.needle.33percent', 'Speed', 'meter,shutoff,main,emergency', 3, true), - (4, NOW(), NOW(), 'Check Water Meter for Leaks', 'Turn off all water and check if meter is still moving', 9, 5, 'drop.triangle.fill', 'WaterDrop', 'leak,meter,water,detection', 4, true), - (5, NOW(), NOW(), 'Run Drain Cleaner', 'Use enzyme-based drain cleaner to prevent clogs', 9, 5, 'arrow.down.to.line.circle.fill', 'VerticalAlignBottom', 'drain,clog,cleaner,pipes', 5, true), - (6, NOW(), NOW(), 'Test Water Heater Pressure Relief Valve', 'Lift lever to release some water and ensure valve works', 9, 8, 'bolt.horizontal.fill', 'Compress', 'water heater,pressure,safety,valve', 6, true), - (7, NOW(), NOW(), 'Replace Water Filters', 'Replace whole-house or point-of-use water filters', 9, 6, 'drop.fill', 'Water', 'filter,water,replacement,drinking', 7, true), + (3, NOW(), NOW(), 'Test Gas Shutoffs', 'Verify gas shutoff valves operate correctly', 9, 8, 'flame.fill', 'LocalGasStation', 'gas,shutoff,valve,safety', 3, true), + (4, NOW(), NOW(), 'Test Water Meter Shutoff', 'Ensure main water shutoff at meter is functional', 9, 8, 'gauge.with.dots.needle.33percent', 'Speed', 'meter,shutoff,main,emergency', 4, true), + (5, NOW(), NOW(), 'Check Water Meter for Leaks', 'Turn off all water and check if meter is still moving', 9, 5, 'drop.triangle.fill', 'WaterDrop', 'leak,meter,water,detection', 5, true), + (6, NOW(), NOW(), 'Run Drain Cleaner', 'Use enzyme-based drain cleaner to prevent clogs', 9, 5, 'arrow.down.to.line.circle.fill', 'VerticalAlignBottom', 'drain,clog,cleaner,pipes', 6, true), + (7, NOW(), NOW(), 'Test Water Heater Pressure Relief Valve', 'Lift lever to release some water and ensure valve works', 9, 8, 'bolt.horizontal.fill', 'Compress', 'water heater,pressure,safety,valve', 7, true), + (8, NOW(), NOW(), 'Replace Water Filters', 'Replace whole-house or point-of-use water filters', 9, 6, 'drop.fill', 'Water', 'filter,water,replacement,drinking', 8, true), + (9, NOW(), NOW(), 'Flush Water Heater', 'Drain sediment from bottom of tank', 9, 6, 'drop.degreesign.fill', 'WaterDamage', 'water heater,sediment,flush,maintenance', 9, true), + (10, NOW(), NOW(), 'Inspect for Leaks', 'Check under sinks and around toilets for water damage', 9, 5, 'exclamationmark.triangle.fill', 'Warning', 'leak,inspection,water damage,plumbing', 10, true), + (11, NOW(), NOW(), 'Inspect Caulking', 'Check bathroom and kitchen caulk for cracks or mold', 9, 5, 'seal.fill', 'LineWeight', 'caulk,bathroom,kitchen,seal', 11, true), + (12, NOW(), NOW(), 'Septic Tank Inspection', 'Have septic system inspected and pumped if needed', 9, 8, 'arrow.down.to.line.circle.fill', 'VerticalAlignBottom', 'septic,tank,pump,inspection', 12, true), + (13, NOW(), NOW(), 'Winterize Outdoor Faucets', 'Shut off water supply and drain lines before freeze', 9, 8, 'snowflake', 'AcUnit', 'winterize,faucet,freeze,outdoor', 13, true), + (14, NOW(), NOW(), 'Replace Fridge Water Line', 'Replace refrigerator water supply hose', 9, 8, 'refrigerator.fill', 'Kitchen', 'refrigerator,hose,water line,replacement', 14, true), + (15, NOW(), NOW(), 'Replace Laundry Hoses', 'Replace washing machine supply hoses to prevent flooding', 9, 8, 'washer.fill', 'LocalLaundryService', 'laundry,hoses,washing machine,flood prevention', 15, true), + (16, NOW(), NOW(), 'Test Water Sensors', 'Test water leak sensors and replace batteries', 9, 6, 'sensor.tag.radiowaves.forward.fill', 'Sensors', 'water sensor,leak detection,battery,test', 16, true), -- SAFETY (category_id = 10) - (8, NOW(), NOW(), 'Test Smoke Detectors', 'Press test button on each detector and replace batteries if needed', 10, 5, 'smoke.fill', 'Sensors', 'smoke,detector,safety,fire', 8, true), - (9, NOW(), NOW(), 'Test Carbon Monoxide Detectors', 'Press test button and verify alarm sounds', 10, 5, 'sensor.tag.radiowaves.forward.fill', 'Air', 'carbon monoxide,detector,safety,gas', 9, true), - (10, NOW(), NOW(), 'Inspect Fire Extinguisher', 'Check pressure gauge and ensure pin is intact', 10, 8, 'flame.fill', 'LocalFireDepartment', 'fire,extinguisher,safety,inspection', 10, true), - (11, NOW(), NOW(), 'Replace Smoke Detector Batteries', 'Replace batteries in all smoke and CO detectors', 10, 8, 'battery.100', 'Battery5Bar', 'battery,smoke,detector,safety', 11, true), - (12, NOW(), NOW(), 'Test GFCI Outlets', 'Press test/reset buttons on all GFCI outlets', 10, 5, 'poweroutlet.type.b', 'ElectricalServices', 'gfci,outlet,safety,electrical', 12, true), - (13, NOW(), NOW(), 'Check Emergency Exits', 'Verify all exits are clear and doors open freely', 10, 6, 'door.left.hand.open', 'DoorFront', 'emergency,exit,safety,door', 13, true), + (17, NOW(), NOW(), 'Test Smoke and Carbon Monoxide Detectors', 'Press test button on each detector and verify alarm sounds', 10, 5, 'smoke.fill', 'Sensors', 'smoke,carbon monoxide,detector,safety,fire', 17, true), + (18, NOW(), NOW(), 'Check Fire Extinguishers', 'Check pressure gauge and ensure accessibility', 10, 6, 'flame.fill', 'LocalFireDepartment', 'fire,extinguisher,safety,inspection', 18, true), + (19, NOW(), NOW(), 'Replace Smoke and CO Detector Batteries', 'Replace batteries in all smoke and CO detectors', 10, 7, 'battery.100', 'Battery5Bar', 'battery,smoke,detector,safety', 19, true), + (20, NOW(), NOW(), 'Replace Smoke and CO Detectors', 'Replace detectors every 10 years or as recommended', 10, 8, 'smoke.fill', 'Sensors', 'smoke,carbon monoxide,replacement,safety', 20, true), + (21, NOW(), NOW(), 'Test GFCI Outlets', 'Press test/reset buttons on all GFCI outlets', 10, 5, 'poweroutlet.type.b', 'ElectricalServices', 'gfci,outlet,safety,electrical', 21, true), + (22, NOW(), NOW(), 'Schedule Chimney Cleaning', 'Professional chimney inspection and cleaning before heating season', 10, 8, 'flame.fill', 'Fireplace', 'chimney,fireplace,cleaning,inspection', 22, true), + (23, NOW(), NOW(), 'Termite Inspection', 'Professional inspection for wood-destroying insects', 10, 8, 'ant.fill', 'BugReport', 'termite,pest,inspection,wood damage', 23, true), + (24, NOW(), NOW(), 'Pest Control Treatment', 'Inspect for signs of pests; treat or call professional', 10, 6, 'ant.fill', 'BugReport', 'pest,control,inspection,treatment', 24, true), + (25, NOW(), NOW(), 'Check/Charge Security Cameras', 'Ensure wireless cameras are functioning and charged', 10, 3, 'video.fill', 'Videocam', 'security,camera,battery,charge', 25, true), -- HVAC (category_id = 6) - (14, NOW(), NOW(), 'Replace HVAC Filter', 'Replace air filter to maintain air quality and efficiency', 6, 5, 'air.purifier', 'Air', 'hvac,filter,air,replacement', 14, true), - (15, NOW(), NOW(), 'Clean AC Condensate Drain', 'Clear drain line to prevent clogs and water damage', 6, 6, 'drop.degreesign.fill', 'WaterDamage', 'ac,condensate,drain,clog', 15, true), - (16, NOW(), NOW(), 'Schedule HVAC Service', 'Annual professional maintenance for heating and cooling system', 6, 8, 'wrench.and.screwdriver', 'Build', 'hvac,service,maintenance,professional', 16, true), - (17, NOW(), NOW(), 'Clean Air Vents and Registers', 'Remove dust and debris from all vents', 6, 6, 'wind', 'AirPurifier', 'vent,register,dust,cleaning', 17, true), - (18, NOW(), NOW(), 'Test Thermostat', 'Verify thermostat accurately controls heating and cooling', 6, 7, 'thermometer.sun.fill', 'Thermostat', 'thermostat,hvac,test,calibration', 18, true), - (19, NOW(), NOW(), 'Inspect Ductwork', 'Check for leaks, damage, or disconnected sections', 6, 8, 'rectangle.3.group', 'Hvac', 'duct,ductwork,leak,inspection', 19, true), - (20, NOW(), NOW(), 'Clean Outdoor AC Unit', 'Remove debris and clean condenser coils', 6, 8, 'fan', 'WindPower', 'ac,condenser,outdoor,cleaning', 20, true), - (21, NOW(), NOW(), 'Check Refrigerant Levels', 'Have professional check AC refrigerant', 6, 8, 'snowflake', 'AcUnit', 'refrigerant,ac,freon,professional', 21, true), - (22, NOW(), NOW(), 'Inspect Heat Pump', 'Check heat pump operation and clean coils', 6, 7, 'heat.waves', 'HeatPump', 'heat pump,inspection,coils,maintenance', 22, true), + (26, NOW(), NOW(), 'Change HVAC Filters', 'Replace air conditioning/furnace filters for efficiency', 6, 5, 'air.purifier', 'Air', 'hvac,filter,air,replacement', 26, true), + (27, NOW(), NOW(), 'Flush HVAC Drain Lines', 'Clear condensate drain line to prevent clogs and water damage', 6, 6, 'drop.degreesign.fill', 'WaterDamage', 'hvac,condensate,drain,clog', 27, true), + (28, NOW(), NOW(), 'Clean Return Vents', 'Remove dust and debris from return air vents', 6, 6, 'wind', 'AirPurifier', 'vent,return,dust,cleaning', 28, true), + (29, NOW(), NOW(), 'Clean Floor Registers', 'Remove and clean floor heating/cooling registers', 6, 6, 'rectangle.grid.1x2.fill', 'GridOn', 'register,floor,vent,cleaning', 29, true), + (30, NOW(), NOW(), 'Clean HVAC Compressor Coils', 'Clean outdoor AC unit condenser coils', 6, 8, 'fan', 'WindPower', 'ac,condenser,coils,outdoor,cleaning', 30, true), + (31, NOW(), NOW(), 'Schedule HVAC Inspection and Service', 'Professional maintenance for heating and cooling system', 6, 7, 'wrench.and.screwdriver', 'Build', 'hvac,service,maintenance,professional', 31, true), -- APPLIANCES (category_id = 1) - (23, NOW(), NOW(), 'Clean Refrigerator Coils', 'Vacuum condenser coils to improve efficiency', 1, 7, 'refrigerator.fill', 'Kitchen', 'refrigerator,coils,cleaning,efficiency', 23, true), - (24, NOW(), NOW(), 'Clean Dishwasher Filter', 'Remove and clean the dishwasher filter', 1, 5, 'dishwasher.fill', 'LocalLaundryService', 'dishwasher,filter,cleaning', 24, true), - (25, NOW(), NOW(), 'Clean Washing Machine', 'Run cleaning cycle or use washing machine cleaner', 1, 5, 'washer.fill', 'LocalLaundryService', 'washing machine,cleaning,maintenance', 25, true), - (26, NOW(), NOW(), 'Clean Dryer Vent', 'Clean lint from dryer vent and ductwork', 1, 8, 'dryer.fill', 'LocalLaundryService', 'dryer,vent,lint,fire hazard', 26, true), - (27, NOW(), NOW(), 'Clean Range Hood Filter', 'Remove and clean grease filter', 1, 6, 'stove.fill', 'Microwave', 'range hood,filter,grease,cleaning', 27, true), - (28, NOW(), NOW(), 'Descale Coffee Maker', 'Run descaling solution through coffee maker', 1, 5, 'cup.and.saucer.fill', 'Coffee', 'coffee,descale,cleaning,appliance', 28, true), - (29, NOW(), NOW(), 'Clean Garbage Disposal', 'Clean and deodorize garbage disposal', 1, 5, 'trash.fill', 'Delete', 'garbage disposal,cleaning,deodorize', 29, true), - (30, NOW(), NOW(), 'Clean Oven', 'Run self-clean cycle or manually clean oven', 1, 6, 'oven.fill', 'Microwave', 'oven,cleaning,grease', 30, true), - (31, NOW(), NOW(), 'Check Refrigerator Seals', 'Inspect door gaskets for cracks or gaps', 1, 7, 'seal.fill', 'DoorSliding', 'refrigerator,seal,gasket,inspection', 31, true), + (32, NOW(), NOW(), 'Clean Microwave', 'Clean interior and exterior of microwave', 1, 5, 'microwave.fill', 'Microwave', 'microwave,cleaning,appliance', 32, true), + (33, NOW(), NOW(), 'Clean Toaster', 'Empty crumb tray and clean toaster interior', 1, 5, 'rectangle.fill', 'BreakfastDining', 'toaster,cleaning,crumbs,appliance', 33, true), + (34, NOW(), NOW(), 'Clean Garbage Disposal', 'Run ice cubes and lemon peels to clean and deodorize', 1, 5, 'trash.fill', 'Delete', 'garbage disposal,cleaning,deodorize', 34, true), + (35, NOW(), NOW(), 'Clean Oven', 'Run self-clean cycle or manually clean oven interior', 1, 6, 'oven.fill', 'Microwave', 'oven,cleaning,grease', 35, true), + (36, NOW(), NOW(), 'Clean Refrigerator Coils', 'Vacuum dust from condenser coils for efficiency', 1, 6, 'refrigerator.fill', 'Kitchen', 'refrigerator,coils,cleaning,efficiency', 36, true), + (37, NOW(), NOW(), 'Clean Dishwasher Filter', 'Remove and clean the dishwasher food trap/filter', 1, 5, 'dishwasher.fill', 'LocalLaundryService', 'dishwasher,filter,cleaning', 37, true), + (38, NOW(), NOW(), 'Clean Vent Hood Filters', 'Soak and scrub range hood filters to remove grease', 1, 5, 'stove.fill', 'Microwave', 'range hood,filter,grease,cleaning', 38, true), + (39, NOW(), NOW(), 'Clean Dryer Vent', 'Remove lint buildup from dryer vent and ductwork to prevent fires', 1, 7, 'dryer.fill', 'LocalLaundryService', 'dryer,vent,lint,fire hazard', 39, true), + + -- CLEANING (category_id = 2) + (40, NOW(), NOW(), 'Wipe Kitchen Counters', 'Clean countertops and stovetop after cooking', 2, 2, 'sparkles', 'CleaningServices', 'kitchen,counters,stovetop,cleaning', 40, true), + (41, NOW(), NOW(), 'Take Out Trash', 'Empty full trash cans to prevent odors and pests', 2, 2, 'trash.fill', 'Delete', 'trash,garbage,disposal', 41, true), + (42, NOW(), NOW(), 'Vacuum Floors', 'Vacuum all carpets and rugs, especially high-traffic areas', 2, 3, 'rectangle.and.hand.point.up.left.fill', 'Carpet', 'vacuum,floors,carpet,cleaning', 42, true), + (43, NOW(), NOW(), 'Mop Hard Floors', 'Mop tile, hardwood, and laminate floors', 2, 3, 'square.grid.3x3.fill', 'CleaningServices', 'mop,floors,tile,hardwood', 43, true), + (44, NOW(), NOW(), 'Clean Bathrooms', 'Scrub toilets, sinks, showers, and mirrors', 2, 3, 'shower.fill', 'Bathroom', 'bathroom,toilet,shower,cleaning', 44, true), + (45, NOW(), NOW(), 'Change Bed Linens', 'Wash and replace sheets, pillowcases, and mattress covers', 2, 3, 'bed.double.fill', 'Bed', 'bed,linens,sheets,laundry', 45, true), + (46, NOW(), NOW(), 'Do Laundry', 'Wash, dry, fold, and put away clothes', 2, 3, 'washer.fill', 'LocalLaundryService', 'laundry,clothes,washing', 46, true), + (47, NOW(), NOW(), 'Clean Kitchen Appliances', 'Wipe down microwave, dishwasher exterior, coffee maker', 2, 3, 'refrigerator.fill', 'Kitchen', 'kitchen,appliances,cleaning,exterior', 47, true), + (48, NOW(), NOW(), 'Dust Surfaces', 'Dust furniture, shelves, and decorations', 2, 3, 'sparkles', 'CleaningServices', 'dust,furniture,shelves,cleaning', 48, true), + (49, NOW(), NOW(), 'Clean Out Refrigerator', 'Discard expired food and wipe down shelves', 2, 3, 'refrigerator.fill', 'Kitchen', 'refrigerator,cleaning,expired food', 49, true), + (50, NOW(), NOW(), 'Clean Vacuum', 'Empty canister, clean filters, and check for clogs', 2, 5, 'rectangle.and.hand.point.up.left.fill', 'Carpet', 'vacuum,maintenance,filter,cleaning', 50, true), + (51, NOW(), NOW(), 'Clean Bathroom Exhaust Fans', 'Remove covers and clean dust from exhaust fans', 2, 6, 'fan.fill', 'WindPower', 'bathroom,exhaust,fan,cleaning', 51, true), + (52, NOW(), NOW(), 'Vacuum Under Furniture', 'Move furniture to vacuum underneath, especially beds', 2, 5, 'rectangle.and.hand.point.up.left.fill', 'Carpet', 'vacuum,furniture,deep clean', 52, true), + (53, NOW(), NOW(), 'Clean Inside Trash Cans', 'Wash and disinfect garbage and recycling bins', 2, 5, 'trash.fill', 'Delete', 'trash can,cleaning,disinfect', 53, true), + (54, NOW(), NOW(), 'Clean Window Tracks', 'Remove dirt and debris from window and door tracks', 2, 6, 'window.horizontal', 'Window', 'window,tracks,cleaning,debris', 54, true), + (55, NOW(), NOW(), 'Deep Clean Carpets', 'Professional carpet cleaning or DIY steam clean', 2, 7, 'rectangle.and.hand.point.up.left.fill', 'Carpet', 'carpet,deep clean,steam,professional', 55, true), + (56, NOW(), NOW(), 'Clean and Reverse Ceiling Fans', 'Clean fan blades and reverse direction for season', 2, 7, 'fanblades.fill', 'WindPower', 'ceiling fan,cleaning,seasonal,direction', 56, true), -- EXTERIOR (category_id = 4) - (32, NOW(), NOW(), 'Clean Gutters', 'Remove leaves and debris from gutters and downspouts', 4, 7, 'house.fill', 'Roofing', 'gutter,cleaning,leaves,debris', 32, true), - (33, NOW(), NOW(), 'Inspect Roof', 'Check for damaged, missing, or loose shingles', 4, 8, 'house.circle.fill', 'Roofing', 'roof,shingles,inspection,damage', 33, true), - (34, NOW(), NOW(), 'Power Wash Exterior', 'Clean siding, walkways, and driveway', 4, 8, 'water.waves', 'CleaningServices', 'power wash,siding,driveway,cleaning', 34, true), - (35, NOW(), NOW(), 'Seal Driveway', 'Apply sealant to asphalt or concrete driveway', 4, 8, 'road.lanes', 'RoundaboutRight', 'driveway,seal,asphalt,concrete', 35, true), - (36, NOW(), NOW(), 'Inspect Foundation', 'Check for cracks or signs of settling', 4, 8, 'building.2.fill', 'Foundation', 'foundation,cracks,inspection,settling', 36, true), - (37, NOW(), NOW(), 'Clean Window Exteriors', 'Wash exterior window surfaces', 4, 7, 'window.horizontal', 'Window', 'window,exterior,cleaning,glass', 37, true), - (38, NOW(), NOW(), 'Inspect Deck/Patio', 'Check for rot, loose boards, or damage', 4, 8, 'rectangle.split.3x1.fill', 'Deck', 'deck,patio,inspection,rot', 38, true), - (39, NOW(), NOW(), 'Stain/Seal Deck', 'Apply stain or sealant to protect wood deck', 4, 8, 'paintbrush.fill', 'FormatPaint', 'deck,stain,seal,wood', 39, true), - - -- LAWN & GARDEN (category_id = 4, but could be General category = 5) - (40, NOW(), NOW(), 'Mow Lawn', 'Cut grass to appropriate height', 4, 3, 'leaf.fill', 'Grass', 'lawn,mowing,grass,yard', 40, true), - (41, NOW(), NOW(), 'Trim Trees/Shrubs', 'Prune overgrown branches and shape shrubs', 4, 8, 'tree.fill', 'Park', 'tree,shrub,pruning,trimming', 41, true), - (42, NOW(), NOW(), 'Fertilize Lawn', 'Apply seasonal fertilizer to lawn', 4, 6, 'drop.fill', 'Opacity', 'fertilizer,lawn,grass,seasonal', 42, true), - (43, NOW(), NOW(), 'Aerate Lawn', 'Aerate soil to improve grass health', 4, 8, 'square.grid.3x3.fill', 'GridOn', 'aerate,lawn,soil,grass', 43, true), - (44, NOW(), NOW(), 'Winterize Irrigation', 'Blow out sprinkler lines before winter', 4, 8, 'snowflake', 'AcUnit', 'irrigation,sprinkler,winterize,freeze', 44, true), - (45, NOW(), NOW(), 'Mulch Garden Beds', 'Add fresh mulch to garden beds', 4, 8, 'leaf.fill', 'Forest', 'mulch,garden,beds,landscaping', 45, true), - - -- ELECTRICAL (category_id = 3) - (46, NOW(), NOW(), 'Test Surge Protectors', 'Check indicator lights on surge protectors', 3, 8, 'bolt.fill', 'ElectricBolt', 'surge,protector,electrical,safety', 46, true), - (47, NOW(), NOW(), 'Check Circuit Breaker Panel', 'Inspect for signs of damage or overheating', 3, 8, 'bolt.square.fill', 'ElectricalServices', 'circuit,breaker,panel,inspection', 47, true), - (48, NOW(), NOW(), 'Replace Outdoor Light Bulbs', 'Check and replace burned out exterior lights', 3, 6, 'lightbulb.fill', 'LightbulbOutline', 'light,bulb,outdoor,replacement', 48, true), - (49, NOW(), NOW(), 'Test Outdoor Lighting Timer', 'Verify timer settings and functionality', 3, 7, 'clock.fill', 'Schedule', 'timer,lighting,outdoor,test', 49, true), - - -- INTERIOR (category_id = 7) - (50, NOW(), NOW(), 'Deep Clean Carpets', 'Steam clean or shampoo carpets', 7, 8, 'rectangle.and.hand.point.up.left.fill', 'Carpet', 'carpet,cleaning,steam,deep clean', 50, true), - (51, NOW(), NOW(), 'Clean Window Interiors', 'Wash interior window surfaces and tracks', 7, 6, 'window.horizontal', 'Window', 'window,interior,cleaning,tracks', 51, true), - (52, NOW(), NOW(), 'Check Caulking', 'Inspect and repair caulk around tubs, showers, sinks', 7, 8, 'sealant.fill', 'LineWeight', 'caulk,bathroom,kitchen,seal', 52, true), - (53, NOW(), NOW(), 'Lubricate Door Hinges', 'Apply lubricant to squeaky hinges', 7, 8, 'door.left.hand.closed', 'MeetingRoom', 'door,hinge,lubricant,squeak', 53, true), - (54, NOW(), NOW(), 'Touch Up Paint', 'Fix scuffs and marks on walls', 7, 8, 'paintbrush.fill', 'ImagesearchRoller', 'paint,walls,touch up,scuffs', 54, true), - (55, NOW(), NOW(), 'Replace Air Fresheners', 'Change out air fresheners throughout home', 7, 5, 'wind', 'Air', 'air freshener,scent,home,fragrance', 55, true), - - -- SEASONAL (category_id = 5 General) - (56, NOW(), NOW(), 'Reverse Ceiling Fan Direction', 'Change rotation for heating vs cooling season', 5, 7, 'fanblades.fill', 'WindPower', 'ceiling fan,direction,seasonal,hvac', 56, true), - (57, NOW(), NOW(), 'Store/Retrieve Seasonal Items', 'Rotate seasonal decorations and equipment', 5, 7, 'archivebox.fill', 'Archive', 'seasonal,storage,decorations,rotation', 57, true), - (58, NOW(), NOW(), 'Check Weather Stripping', 'Inspect and replace worn weather stripping on doors/windows', 5, 8, 'wind', 'Air', 'weather stripping,door,window,insulation', 58, true), - (59, NOW(), NOW(), 'Service Snow Blower', 'Prepare snow blower for winter season', 5, 8, 'snowflake', 'AcUnit', 'snow blower,service,winter,maintenance', 59, true), - (60, NOW(), NOW(), 'Service Lawn Mower', 'Change oil, sharpen blade, replace spark plug', 5, 8, 'leaf.fill', 'Grass', 'lawn mower,service,maintenance,spring', 60, true) + (57, NOW(), NOW(), 'Clean Gutters', 'Remove leaves and debris; check for proper drainage', 4, 7, 'house.fill', 'Roofing', 'gutter,cleaning,leaves,debris', 57, true), + (58, NOW(), NOW(), 'Wash Windows', 'Clean interior and exterior glass and screens', 4, 7, 'window.horizontal', 'Window', 'window,cleaning,glass,screens', 58, true), + (59, NOW(), NOW(), 'Inspect Roof', 'Look for missing shingles, damage, or debris', 4, 7, 'house.circle.fill', 'Roofing', 'roof,shingles,inspection,damage', 59, true), + (60, NOW(), NOW(), 'Service Garage Door', 'Lubricate springs, hinges, and rollers', 4, 8, 'door.garage.closed', 'Garage', 'garage door,lubricate,maintenance', 60, true), + (61, NOW(), NOW(), 'Inspect Weather Stripping', 'Check doors and windows; replace worn seals', 4, 8, 'wind', 'Air', 'weather stripping,door,window,insulation', 61, true), + (62, NOW(), NOW(), 'Pressure Wash Exterior', 'Clean siding, driveway, sidewalks, and deck', 4, 8, 'water.waves', 'CleaningServices', 'power wash,siding,driveway,cleaning', 62, true), + (63, NOW(), NOW(), 'Touch Up Exterior Paint', 'Address peeling or cracking paint to prevent moisture damage', 4, 8, 'paintbrush.fill', 'FormatPaint', 'paint,exterior,touch up,maintenance', 63, true), + (64, NOW(), NOW(), 'Service Sprinkler System', 'Inspect heads, adjust coverage, winterize if needed', 4, 8, 'sprinkler.and.droplets.fill', 'Grass', 'sprinkler,irrigation,winterize,lawn', 64, true), + (65, NOW(), NOW(), 'Weed Garden Beds', 'Remove weeds and prune plants as needed', 4, 5, 'leaf.fill', 'Grass', 'garden,weeds,pruning,landscaping', 65, true), + (66, NOW(), NOW(), 'Water Indoor Plants', 'Check soil moisture and water as needed', 4, 3, 'leaf.fill', 'Eco', 'plants,watering,indoor,gardening', 66, true) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title,