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 package handlers
import ( import (
"context"
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto" "github.com/treytartt/casera-api/internal/admin/dto"
"github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
) )
// AdminLookupHandler handles admin lookup table management endpoints // AdminLookupHandler handles admin lookup table management endpoints
@@ -21,6 +24,159 @@ func NewAdminLookupHandler(db *gorm.DB) *AdminLookupHandler {
return &AdminLookupHandler{db: db} 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 ========== // ========== Task Categories ==========
type TaskCategoryResponse struct { type TaskCategoryResponse struct {
@@ -84,6 +240,9 @@ func (h *AdminLookupHandler) CreateCategory(c *gin.Context) {
return return
} }
// Refresh cache after creating
h.refreshCategoriesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskCategoryResponse{ c.JSON(http.StatusCreated, TaskCategoryResponse{
ID: category.ID, ID: category.ID,
Name: category.Name, Name: category.Name,
@@ -130,6 +289,9 @@ func (h *AdminLookupHandler) UpdateCategory(c *gin.Context) {
return return
} }
// Refresh cache after updating
h.refreshCategoriesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskCategoryResponse{ c.JSON(http.StatusOK, TaskCategoryResponse{
ID: category.ID, ID: category.ID,
Name: category.Name, Name: category.Name,
@@ -160,6 +322,9 @@ func (h *AdminLookupHandler) DeleteCategory(c *gin.Context) {
return return
} }
// Refresh cache after deleting
h.refreshCategoriesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Category deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Category deleted successfully"})
} }
@@ -222,6 +387,9 @@ func (h *AdminLookupHandler) CreatePriority(c *gin.Context) {
return return
} }
// Refresh cache after creating
h.refreshPrioritiesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskPriorityResponse{ c.JSON(http.StatusCreated, TaskPriorityResponse{
ID: priority.ID, ID: priority.ID,
Name: priority.Name, Name: priority.Name,
@@ -266,6 +434,9 @@ func (h *AdminLookupHandler) UpdatePriority(c *gin.Context) {
return return
} }
// Refresh cache after updating
h.refreshPrioritiesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskPriorityResponse{ c.JSON(http.StatusOK, TaskPriorityResponse{
ID: priority.ID, ID: priority.ID,
Name: priority.Name, Name: priority.Name,
@@ -294,6 +465,9 @@ func (h *AdminLookupHandler) DeletePriority(c *gin.Context) {
return return
} }
// Refresh cache after deleting
h.refreshPrioritiesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"})
} }
@@ -356,6 +530,9 @@ func (h *AdminLookupHandler) CreateStatus(c *gin.Context) {
return return
} }
// Refresh cache after creating
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskStatusResponse{ c.JSON(http.StatusCreated, TaskStatusResponse{
ID: status.ID, ID: status.ID,
Name: status.Name, Name: status.Name,
@@ -400,6 +577,9 @@ func (h *AdminLookupHandler) UpdateStatus(c *gin.Context) {
return return
} }
// Refresh cache after updating
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskStatusResponse{ c.JSON(http.StatusOK, TaskStatusResponse{
ID: status.ID, ID: status.ID,
Name: status.Name, Name: status.Name,
@@ -428,6 +608,9 @@ func (h *AdminLookupHandler) DeleteStatus(c *gin.Context) {
return return
} }
// Refresh cache after deleting
h.refreshStatusesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Status deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Status deleted successfully"})
} }
@@ -486,6 +669,9 @@ func (h *AdminLookupHandler) CreateFrequency(c *gin.Context) {
return return
} }
// Refresh cache after creating
h.refreshFrequenciesCache(c.Request.Context())
c.JSON(http.StatusCreated, TaskFrequencyResponse{ c.JSON(http.StatusCreated, TaskFrequencyResponse{
ID: frequency.ID, ID: frequency.ID,
Name: frequency.Name, Name: frequency.Name,
@@ -528,6 +714,9 @@ func (h *AdminLookupHandler) UpdateFrequency(c *gin.Context) {
return return
} }
// Refresh cache after updating
h.refreshFrequenciesCache(c.Request.Context())
c.JSON(http.StatusOK, TaskFrequencyResponse{ c.JSON(http.StatusOK, TaskFrequencyResponse{
ID: frequency.ID, ID: frequency.ID,
Name: frequency.Name, Name: frequency.Name,
@@ -555,6 +744,9 @@ func (h *AdminLookupHandler) DeleteFrequency(c *gin.Context) {
return return
} }
// Refresh cache after deleting
h.refreshFrequenciesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Frequency deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Frequency deleted successfully"})
} }
@@ -600,6 +792,9 @@ func (h *AdminLookupHandler) CreateResidenceType(c *gin.Context) {
return return
} }
// Refresh cache after creating
h.refreshResidenceTypesCache(c.Request.Context())
c.JSON(http.StatusCreated, ResidenceTypeResponse{ c.JSON(http.StatusCreated, ResidenceTypeResponse{
ID: residenceType.ID, ID: residenceType.ID,
Name: residenceType.Name, Name: residenceType.Name,
@@ -635,6 +830,9 @@ func (h *AdminLookupHandler) UpdateResidenceType(c *gin.Context) {
return return
} }
// Refresh cache after updating
h.refreshResidenceTypesCache(c.Request.Context())
c.JSON(http.StatusOK, ResidenceTypeResponse{ c.JSON(http.StatusOK, ResidenceTypeResponse{
ID: residenceType.ID, ID: residenceType.ID,
Name: residenceType.Name, Name: residenceType.Name,
@@ -660,6 +858,9 @@ func (h *AdminLookupHandler) DeleteResidenceType(c *gin.Context) {
return return
} }
// Refresh cache after deleting
h.refreshResidenceTypesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Residence type deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Residence type deleted successfully"})
} }
@@ -722,6 +923,9 @@ func (h *AdminLookupHandler) CreateSpecialty(c *gin.Context) {
return return
} }
// Refresh cache after creating
h.refreshSpecialtiesCache(c.Request.Context())
c.JSON(http.StatusCreated, ContractorSpecialtyResponse{ c.JSON(http.StatusCreated, ContractorSpecialtyResponse{
ID: specialty.ID, ID: specialty.ID,
Name: specialty.Name, Name: specialty.Name,
@@ -766,6 +970,9 @@ func (h *AdminLookupHandler) UpdateSpecialty(c *gin.Context) {
return return
} }
// Refresh cache after updating
h.refreshSpecialtiesCache(c.Request.Context())
c.JSON(http.StatusOK, ContractorSpecialtyResponse{ c.JSON(http.StatusOK, ContractorSpecialtyResponse{
ID: specialty.ID, ID: specialty.ID,
Name: specialty.Name, Name: specialty.Name,
@@ -795,6 +1002,9 @@ func (h *AdminLookupHandler) DeleteSpecialty(c *gin.Context) {
return return
} }
// Refresh cache after deleting
h.refreshSpecialtiesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Specialty deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Specialty deleted successfully"})
} }

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -8,9 +9,11 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
) )
// AdminSettingsHandler handles system settings management // AdminSettingsHandler handles system settings management
@@ -85,7 +88,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
} }
// SeedLookups handles POST /api/admin/settings/seed-lookups // 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) { func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
// First seed lookup tables // First seed lookup tables
if err := h.runSeedFile("001_lookups.sql"); err != nil { if err := h.runSeedFile("001_lookups.sql"); err != nil {
@@ -99,7 +102,230 @@ func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
return 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 // SeedTestData handles POST /api/admin/settings/seed-test-data

View File

@@ -1,14 +1,17 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
) )
// AdminTaskTemplateHandler handles admin task template management endpoints // AdminTaskTemplateHandler handles admin task template management endpoints
@@ -21,6 +24,36 @@ func NewAdminTaskTemplateHandler(db *gorm.DB) *AdminTaskTemplateHandler {
return &AdminTaskTemplateHandler{db: db} 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 // TaskTemplateResponse represents a task template in admin responses
type TaskTemplateResponse struct { type TaskTemplateResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
@@ -145,6 +178,9 @@ func (h *AdminTaskTemplateHandler) CreateTemplate(c *gin.Context) {
// Reload with preloads // Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID) 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)) c.JSON(http.StatusCreated, h.toResponse(&template))
} }
@@ -195,6 +231,9 @@ func (h *AdminTaskTemplateHandler) UpdateTemplate(c *gin.Context) {
// Reload with preloads // Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID) 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)) c.JSON(http.StatusOK, h.toResponse(&template))
} }
@@ -211,6 +250,9 @@ func (h *AdminTaskTemplateHandler) DeleteTemplate(c *gin.Context) {
return return
} }
// Refresh cache after deleting
h.refreshTaskTemplatesCache(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "Template deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Template deleted successfully"})
} }
@@ -241,6 +283,9 @@ func (h *AdminTaskTemplateHandler) ToggleActive(c *gin.Context) {
// Reload with preloads // Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID) 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)) c.JSON(http.StatusOK, h.toResponse(&template))
} }
@@ -279,6 +324,9 @@ func (h *AdminTaskTemplateHandler) BulkCreate(c *gin.Context) {
return return
} }
// Refresh cache after bulk creating
h.refreshTaskTemplatesCache(c.Request.Context())
c.JSON(http.StatusCreated, gin.H{"message": "Templates created successfully", "count": len(templates)}) c.JSON(http.StatusCreated, gin.H{"message": "Templates created successfully", "count": len(templates)})
} }

View File

@@ -4,16 +4,32 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "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/i18n"
"github.com/treytartt/casera-api/internal/services" "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 // StaticDataHandler handles static/lookup data endpoints
type StaticDataHandler struct { type StaticDataHandler struct {
residenceService *services.ResidenceService residenceService *services.ResidenceService
taskService *services.TaskService taskService *services.TaskService
contractorService *services.ContractorService contractorService *services.ContractorService
taskTemplateService *services.TaskTemplateService
cache *services.CacheService
} }
// NewStaticDataHandler creates a new static data handler // NewStaticDataHandler creates a new static data handler
@@ -21,18 +37,56 @@ func NewStaticDataHandler(
residenceService *services.ResidenceService, residenceService *services.ResidenceService,
taskService *services.TaskService, taskService *services.TaskService,
contractorService *services.ContractorService, contractorService *services.ContractorService,
taskTemplateService *services.TaskTemplateService,
cache *services.CacheService,
) *StaticDataHandler { ) *StaticDataHandler {
return &StaticDataHandler{ return &StaticDataHandler{
residenceService: residenceService, residenceService: residenceService,
taskService: taskService, taskService: taskService,
contractorService: contractorService, contractorService: contractorService,
taskTemplateService: taskTemplateService,
cache: cache,
} }
} }
// GetStaticData handles GET /api/static_data/ // 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) { 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() residenceTypes, err := h.residenceService.GetResidenceTypes()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_residence_types")}) 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 return
} }
c.JSON(http.StatusOK, gin.H{ taskTemplates, err := h.taskTemplateService.GetGrouped()
"residence_types": residenceTypes, if err != nil {
"task_categories": taskCategories, c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_templates")})
"task_priorities": taskPriorities, return
"task_frequencies": taskFrequencies, }
"task_statuses": taskStatuses,
"contractor_specialties": contractorSpecialties, // 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/ // RefreshStaticData handles POST /api/static_data/refresh/

View File

@@ -118,7 +118,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
documentHandler := handlers.NewDocumentHandler(documentService, deps.StorageService) documentHandler := handlers.NewDocumentHandler(documentService, deps.StorageService)
notificationHandler := handlers.NewNotificationHandler(notificationService) notificationHandler := handlers.NewNotificationHandler(notificationService)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService) subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService)
staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService) staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService, taskTemplateService, deps.Cache)
taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService) taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService)
// Initialize upload handler (if storage service is available) // Initialize upload handler (if storage service is available)

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"context" "context"
"crypto/md5"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "time"
@@ -161,3 +162,199 @@ func (c *CacheService) GetCachedStaticData(ctx context.Context, dest interface{}
func (c *CacheService) InvalidateStaticData(ctx context.Context) error { func (c *CacheService) InvalidateStaticData(ctx context.Context) error {
return c.Delete(ctx, StaticDataKey) 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)
}

View File

@@ -3,23 +3,23 @@
-- Note: Run seed-lookups first to populate lookup tables -- Note: Run seed-lookups first to populate lookup tables
-- ===================================================== -- =====================================================
-- TEST USERS (password is 'password123' for all users) -- TEST USERS (password is 'test1234' for all users)
-- bcrypt hash: $2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi -- 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) INSERT INTO auth_user (id, username, password, email, first_name, last_name, is_active, is_staff, is_superuser, date_joined, last_login)
VALUES VALUES
(1, 'admin', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'admin@mycrib.com', 'Admin', 'User', true, true, true, NOW() - INTERVAL '1 year', NOW() - INTERVAL '1 hour'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john.doe@example.com', 'John', 'Doe', true, false, false, NOW() - INTERVAL '6 months', NOW() - INTERVAL '2 hours'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'jane.smith@example.com', 'Jane', 'Smith', true, false, false, NOW() - INTERVAL '5 months', NOW() - INTERVAL '1 day'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'bob.wilson@example.com', 'Bob', 'Wilson', true, false, false, NOW() - INTERVAL '4 months', NOW() - INTERVAL '3 days'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'alice.johnson@example.com', 'Alice', 'Johnson', true, false, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '1 week'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'charlie.brown@example.com', 'Charlie', 'Brown', true, false, false, NOW() - INTERVAL '2 months', NOW() - INTERVAL '2 weeks'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'diana.ross@example.com', 'Diana', 'Ross', true, false, false, NOW() - INTERVAL '1 month', NULL), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'edward.norton@example.com', 'Edward', 'Norton', true, false, false, NOW() - INTERVAL '2 weeks', NOW() - INTERVAL '5 days'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'fiona.apple@example.com', 'Fiona', 'Apple', true, false, false, NOW() - INTERVAL '1 week', NOW()), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'inactive@example.com', 'Inactive', 'User', false, false, false, NOW() - INTERVAL '1 year', NULL), (10, 'inactive.user', '$2a$10$GOwh8Qy3Djnp4Pjx3smC9OWBJKX0obXCVcKTLVupLcwACfz8qozfK', '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'), (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$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'george@example.com', 'George', 'Harrison', true, false, false, NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days') (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 ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username, password = EXCLUDED.password, email = EXCLUDED.email, username = EXCLUDED.username, password = EXCLUDED.password, email = EXCLUDED.email,
first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, is_active = EXCLUDED.is_active, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, is_active = EXCLUDED.is_active,

View File

@@ -9,86 +9,86 @@
-- 1 = Once, 2 = Daily, 3 = Weekly, 4 = Bi-Weekly, 5 = Monthly -- 1 = Once, 2 = Daily, 3 = Weekly, 4 = Bi-Weekly, 5 = Monthly
-- 6 = Quarterly, 7 = Semi-Annually, 8 = Annually -- 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) 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 VALUES
-- PLUMBING (category_id = 9) -- 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), (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), (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), (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(), '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), (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(), '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), (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(), '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), (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(), 'Replace Water Filters', 'Replace whole-house or point-of-use water filters', 9, 6, 'drop.fill', 'Water', 'filter,water,replacement,drinking', 7, 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) -- 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), (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),
(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), (18, NOW(), NOW(), 'Check Fire Extinguishers', 'Check pressure gauge and ensure accessibility', 10, 6, 'flame.fill', 'LocalFireDepartment', 'fire,extinguisher,safety,inspection', 18, 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), (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),
(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), (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),
(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), (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),
(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), (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) -- 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), (26, NOW(), NOW(), 'Change HVAC Filters', 'Replace air conditioning/furnace filters for efficiency', 6, 5, 'air.purifier', 'Air', 'hvac,filter,air,replacement', 26, 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), (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),
(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), (28, NOW(), NOW(), 'Clean Return Vents', 'Remove dust and debris from return air vents', 6, 6, 'wind', 'AirPurifier', 'vent,return,dust,cleaning', 28, 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), (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),
(18, NOW(), NOW(), 'Test Thermostat', 'Verify thermostat accurately controls heating and cooling', 6, 7, 'thermometer.sun.fill', 'Thermostat', 'thermostat,hvac,test,calibration', 18, 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),
(19, NOW(), NOW(), 'Inspect Ductwork', 'Check for leaks, damage, or disconnected sections', 6, 8, 'rectangle.3.group', 'Hvac', 'duct,ductwork,leak,inspection', 19, 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),
(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),
-- APPLIANCES (category_id = 1) -- 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), (32, NOW(), NOW(), 'Clean Microwave', 'Clean interior and exterior of microwave', 1, 5, 'microwave.fill', 'Microwave', 'microwave,cleaning,appliance', 32, true),
(24, NOW(), NOW(), 'Clean Dishwasher Filter', 'Remove and clean the dishwasher filter', 1, 5, 'dishwasher.fill', 'LocalLaundryService', 'dishwasher,filter,cleaning', 24, true), (33, NOW(), NOW(), 'Clean Toaster', 'Empty crumb tray and clean toaster interior', 1, 5, 'rectangle.fill', 'BreakfastDining', 'toaster,cleaning,crumbs,appliance', 33, 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), (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),
(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), (35, NOW(), NOW(), 'Clean Oven', 'Run self-clean cycle or manually clean oven interior', 1, 6, 'oven.fill', 'Microwave', 'oven,cleaning,grease', 35, 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), (36, NOW(), NOW(), 'Clean Refrigerator Coils', 'Vacuum dust from condenser coils for efficiency', 1, 6, 'refrigerator.fill', 'Kitchen', 'refrigerator,coils,cleaning,efficiency', 36, 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), (37, NOW(), NOW(), 'Clean Dishwasher Filter', 'Remove and clean the dishwasher food trap/filter', 1, 5, 'dishwasher.fill', 'LocalLaundryService', 'dishwasher,filter,cleaning', 37, true),
(29, NOW(), NOW(), 'Clean Garbage Disposal', 'Clean and deodorize garbage disposal', 1, 5, 'trash.fill', 'Delete', 'garbage disposal,cleaning,deodorize', 29, 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),
(30, NOW(), NOW(), 'Clean Oven', 'Run self-clean cycle or manually clean oven', 1, 6, 'oven.fill', 'Microwave', 'oven,cleaning,grease', 30, 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),
(31, NOW(), NOW(), 'Check Refrigerator Seals', 'Inspect door gaskets for cracks or gaps', 1, 7, 'seal.fill', 'DoorSliding', 'refrigerator,seal,gasket,inspection', 31, 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) -- 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), (57, NOW(), NOW(), 'Clean Gutters', 'Remove leaves and debris; check for proper drainage', 4, 7, 'house.fill', 'Roofing', 'gutter,cleaning,leaves,debris', 57, 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), (58, NOW(), NOW(), 'Wash Windows', 'Clean interior and exterior glass and screens', 4, 7, 'window.horizontal', 'Window', 'window,cleaning,glass,screens', 58, true),
(34, NOW(), NOW(), 'Power Wash Exterior', 'Clean siding, walkways, and driveway', 4, 8, 'water.waves', 'CleaningServices', 'power wash,siding,driveway,cleaning', 34, 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),
(35, NOW(), NOW(), 'Seal Driveway', 'Apply sealant to asphalt or concrete driveway', 4, 8, 'road.lanes', 'RoundaboutRight', 'driveway,seal,asphalt,concrete', 35, true), (60, NOW(), NOW(), 'Service Garage Door', 'Lubricate springs, hinges, and rollers', 4, 8, 'door.garage.closed', 'Garage', 'garage door,lubricate,maintenance', 60, 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), (61, NOW(), NOW(), 'Inspect Weather Stripping', 'Check doors and windows; replace worn seals', 4, 8, 'wind', 'Air', 'weather stripping,door,window,insulation', 61, true),
(37, NOW(), NOW(), 'Clean Window Exteriors', 'Wash exterior window surfaces', 4, 7, 'window.horizontal', 'Window', 'window,exterior,cleaning,glass', 37, 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),
(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), (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),
(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), (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),
-- LAWN & GARDEN (category_id = 4, but could be General category = 5) (66, NOW(), NOW(), 'Water Indoor Plants', 'Check soil moisture and water as needed', 4, 3, 'leaf.fill', 'Eco', 'plants,watering,indoor,gardening', 66, true)
(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)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,