diff --git a/internal/dto/requests/residence.go b/internal/dto/requests/residence.go index a96f1cc..f05bcfb 100644 --- a/internal/dto/requests/residence.go +++ b/internal/dto/requests/residence.go @@ -25,6 +25,22 @@ type CreateResidenceRequest struct { PurchaseDate *time.Time `json:"purchase_date"` PurchasePrice *decimal.Decimal `json:"purchase_price"` IsPrimary *bool `json:"is_primary"` + + // Home Profile + HeatingType *string `json:"heating_type" validate:"omitempty,oneof=gas_furnace electric_furnace heat_pump boiler radiant other"` + CoolingType *string `json:"cooling_type" validate:"omitempty,oneof=central_ac window_ac heat_pump evaporative none other"` + WaterHeaterType *string `json:"water_heater_type" validate:"omitempty,oneof=tank_gas tank_electric tankless_gas tankless_electric heat_pump solar other"` + RoofType *string `json:"roof_type" validate:"omitempty,oneof=asphalt_shingle metal tile slate wood_shake flat other"` + HasPool *bool `json:"has_pool"` + HasSprinklerSystem *bool `json:"has_sprinkler_system"` + HasSeptic *bool `json:"has_septic"` + HasFireplace *bool `json:"has_fireplace"` + HasGarage *bool `json:"has_garage"` + HasBasement *bool `json:"has_basement"` + HasAttic *bool `json:"has_attic"` + ExteriorType *string `json:"exterior_type" validate:"omitempty,oneof=brick vinyl_siding wood_siding stucco stone fiber_cement other"` + FlooringPrimary *string `json:"flooring_primary" validate:"omitempty,oneof=hardwood laminate tile carpet vinyl concrete other"` + LandscapingType *string `json:"landscaping_type" validate:"omitempty,oneof=lawn desert xeriscape garden mixed none other"` } // UpdateResidenceRequest represents the request to update a residence @@ -46,6 +62,22 @@ type UpdateResidenceRequest struct { PurchaseDate *time.Time `json:"purchase_date"` PurchasePrice *decimal.Decimal `json:"purchase_price"` IsPrimary *bool `json:"is_primary"` + + // Home Profile + HeatingType *string `json:"heating_type" validate:"omitempty,oneof=gas_furnace electric_furnace heat_pump boiler radiant other"` + CoolingType *string `json:"cooling_type" validate:"omitempty,oneof=central_ac window_ac heat_pump evaporative none other"` + WaterHeaterType *string `json:"water_heater_type" validate:"omitempty,oneof=tank_gas tank_electric tankless_gas tankless_electric heat_pump solar other"` + RoofType *string `json:"roof_type" validate:"omitempty,oneof=asphalt_shingle metal tile slate wood_shake flat other"` + HasPool *bool `json:"has_pool"` + HasSprinklerSystem *bool `json:"has_sprinkler_system"` + HasSeptic *bool `json:"has_septic"` + HasFireplace *bool `json:"has_fireplace"` + HasGarage *bool `json:"has_garage"` + HasBasement *bool `json:"has_basement"` + HasAttic *bool `json:"has_attic"` + ExteriorType *string `json:"exterior_type" validate:"omitempty,oneof=brick vinyl_siding wood_siding stucco stone fiber_cement other"` + FlooringPrimary *string `json:"flooring_primary" validate:"omitempty,oneof=hardwood laminate tile carpet vinyl concrete other"` + LandscapingType *string `json:"landscaping_type" validate:"omitempty,oneof=lawn desert xeriscape garden mixed none other"` } // JoinWithCodeRequest represents the request to join a residence via share code diff --git a/internal/dto/responses/residence.go b/internal/dto/responses/residence.go index bc9afd0..5dfa4d0 100644 --- a/internal/dto/responses/residence.go +++ b/internal/dto/responses/residence.go @@ -46,6 +46,22 @@ type ResidenceResponse struct { Description string `json:"description"` PurchaseDate *time.Time `json:"purchase_date"` PurchasePrice *decimal.Decimal `json:"purchase_price"` + // Home Profile + HeatingType *string `json:"heating_type"` + CoolingType *string `json:"cooling_type"` + WaterHeaterType *string `json:"water_heater_type"` + RoofType *string `json:"roof_type"` + HasPool bool `json:"has_pool"` + HasSprinklerSystem bool `json:"has_sprinkler_system"` + HasSeptic bool `json:"has_septic"` + HasFireplace bool `json:"has_fireplace"` + HasGarage bool `json:"has_garage"` + HasBasement bool `json:"has_basement"` + HasAttic bool `json:"has_attic"` + ExteriorType *string `json:"exterior_type"` + FlooringPrimary *string `json:"flooring_primary"` + LandscapingType *string `json:"landscaping_type"` + IsPrimary bool `json:"is_primary"` IsActive bool `json:"is_active"` OverdueCount int `json:"overdue_count"` @@ -184,9 +200,23 @@ func NewResidenceResponse(residence *models.Residence) ResidenceResponse { YearBuilt: residence.YearBuilt, Description: residence.Description, PurchaseDate: residence.PurchaseDate, - PurchasePrice: residence.PurchasePrice, - IsPrimary: residence.IsPrimary, - IsActive: residence.IsActive, + PurchasePrice: residence.PurchasePrice, + HeatingType: residence.HeatingType, + CoolingType: residence.CoolingType, + WaterHeaterType: residence.WaterHeaterType, + RoofType: residence.RoofType, + HasPool: residence.HasPool, + HasSprinklerSystem: residence.HasSprinklerSystem, + HasSeptic: residence.HasSeptic, + HasFireplace: residence.HasFireplace, + HasGarage: residence.HasGarage, + HasBasement: residence.HasBasement, + HasAttic: residence.HasAttic, + ExteriorType: residence.ExteriorType, + FlooringPrimary: residence.FlooringPrimary, + LandscapingType: residence.LandscapingType, + IsPrimary: residence.IsPrimary, + IsActive: residence.IsActive, CreatedAt: residence.CreatedAt, UpdatedAt: residence.UpdatedAt, } diff --git a/internal/dto/responses/suggestion.go b/internal/dto/responses/suggestion.go new file mode 100644 index 0000000..ab88d6f --- /dev/null +++ b/internal/dto/responses/suggestion.go @@ -0,0 +1,15 @@ +package responses + +// TaskSuggestionResponse represents a single task suggestion with relevance scoring +type TaskSuggestionResponse struct { + Template TaskTemplateResponse `json:"template"` + RelevanceScore float64 `json:"relevance_score"` + MatchReasons []string `json:"match_reasons"` +} + +// TaskSuggestionsResponse represents the full suggestions response +type TaskSuggestionsResponse struct { + Suggestions []TaskSuggestionResponse `json:"suggestions"` + TotalCount int `json:"total_count"` + ProfileCompleteness float64 `json:"profile_completeness"` +} diff --git a/internal/handlers/suggestion_handler.go b/internal/handlers/suggestion_handler.go new file mode 100644 index 0000000..2671c74 --- /dev/null +++ b/internal/handlers/suggestion_handler.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + + "github.com/treytartt/honeydue-api/internal/apperrors" + "github.com/treytartt/honeydue-api/internal/middleware" + "github.com/treytartt/honeydue-api/internal/services" +) + +// SuggestionHandler handles task suggestion endpoints +type SuggestionHandler struct { + suggestionService *services.SuggestionService +} + +// NewSuggestionHandler creates a new suggestion handler +func NewSuggestionHandler(suggestionService *services.SuggestionService) *SuggestionHandler { + return &SuggestionHandler{ + suggestionService: suggestionService, + } +} + +// GetSuggestions handles GET /api/tasks/suggestions/?residence_id=X +// Returns task template suggestions scored against the residence's home profile +func (h *SuggestionHandler) GetSuggestions(c echo.Context) error { + user, err := middleware.MustGetAuthUser(c) + if err != nil { + return err + } + + residenceIDStr := c.QueryParam("residence_id") + if residenceIDStr == "" { + return apperrors.BadRequest("error.residence_id_required") + } + + residenceID, err := strconv.ParseUint(residenceIDStr, 10, 32) + if err != nil { + return apperrors.BadRequest("error.invalid_id") + } + + resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, resp) +} diff --git a/internal/models/residence.go b/internal/models/residence.go index 13b1680..6385d2b 100644 --- a/internal/models/residence.go +++ b/internal/models/residence.go @@ -47,6 +47,22 @@ type Residence struct { PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"` PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(12,2)" json:"purchase_price"` + // Home Profile (for smart onboarding suggestions) + HeatingType *string `gorm:"column:heating_type;size:50" json:"heating_type"` + CoolingType *string `gorm:"column:cooling_type;size:50" json:"cooling_type"` + WaterHeaterType *string `gorm:"column:water_heater_type;size:50" json:"water_heater_type"` + RoofType *string `gorm:"column:roof_type;size:50" json:"roof_type"` + HasPool bool `gorm:"column:has_pool;default:false" json:"has_pool"` + HasSprinklerSystem bool `gorm:"column:has_sprinkler_system;default:false" json:"has_sprinkler_system"` + HasSeptic bool `gorm:"column:has_septic;default:false" json:"has_septic"` + HasFireplace bool `gorm:"column:has_fireplace;default:false" json:"has_fireplace"` + HasGarage bool `gorm:"column:has_garage;default:false" json:"has_garage"` + HasBasement bool `gorm:"column:has_basement;default:false" json:"has_basement"` + HasAttic bool `gorm:"column:has_attic;default:false" json:"has_attic"` + ExteriorType *string `gorm:"column:exterior_type;size:50" json:"exterior_type"` + FlooringPrimary *string `gorm:"column:flooring_primary;size:50" json:"flooring_primary"` + LandscapingType *string `gorm:"column:landscaping_type;size:50" json:"landscaping_type"` + IsPrimary bool `gorm:"column:is_primary;default:true" json:"is_primary"` IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"` // Soft delete flag diff --git a/internal/models/task_template.go b/internal/models/task_template.go index 65e1b83..d25b458 100644 --- a/internal/models/task_template.go +++ b/internal/models/task_template.go @@ -1,19 +1,22 @@ package models +import "encoding/json" + // TaskTemplate represents a predefined task template that users can select when creating tasks type TaskTemplate struct { BaseModel - Title string `gorm:"column:title;size:200;not null" json:"title"` - Description string `gorm:"column:description;type:text" json:"description"` - CategoryID *uint `gorm:"column:category_id;index" json:"category_id"` - Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"` - Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"` - IconIOS string `gorm:"column:icon_ios;size:100" json:"icon_ios"` - IconAndroid string `gorm:"column:icon_android;size:100" json:"icon_android"` - Tags string `gorm:"column:tags;type:text" json:"tags"` // Comma-separated tags for search - DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"` + Title string `gorm:"column:title;size:200;not null" json:"title"` + Description string `gorm:"column:description;type:text" json:"description"` + CategoryID *uint `gorm:"column:category_id;index" json:"category_id"` + Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"` + Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"` + IconIOS string `gorm:"column:icon_ios;size:100" json:"icon_ios"` + IconAndroid string `gorm:"column:icon_android;size:100" json:"icon_android"` + Tags string `gorm:"column:tags;type:text" json:"tags"` // Comma-separated tags for search + DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"` IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"` + Conditions json.RawMessage `gorm:"column:conditions;type:jsonb;default:'{}'" json:"conditions"` Regions []ClimateRegion `gorm:"many2many:task_tasktemplate_regions;" json:"regions,omitempty"` } diff --git a/internal/router/router.go b/internal/router/router.go index a73a1bc..59cab92 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -171,6 +171,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo { subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) residenceService.SetSubscriptionService(subscriptionService) // Wire up subscription service for tier limit enforcement taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo) + suggestionService := services.NewSuggestionService(deps.DB, residenceRepo) // Initialize Stripe service stripeService := services.NewStripeService(subscriptionRepo, userRepo) @@ -207,6 +208,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo { subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, stripeService) staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService, taskTemplateService, deps.Cache) taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService) + suggestionHandler := handlers.NewSuggestionHandler(suggestionService) // Initialize upload handler (if storage service is available) var uploadHandler *handlers.UploadHandler @@ -255,6 +257,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo { setupProtectedAuthRoutes(protected, authHandler) setupResidenceRoutes(protected, residenceHandler) setupTaskRoutes(protected, taskHandler) + setupSuggestionRoutes(protected, suggestionHandler) setupContractorRoutes(protected, contractorHandler) setupDocumentRoutes(protected, documentHandler) setupNotificationRoutes(protected, notificationHandler) @@ -574,6 +577,14 @@ func setupTaskRoutes(api *echo.Group, taskHandler *handlers.TaskHandler) { } } +// setupSuggestionRoutes configures task suggestion routes +func setupSuggestionRoutes(api *echo.Group, suggestionHandler *handlers.SuggestionHandler) { + tasks := api.Group("/tasks") + { + tasks.GET("/suggestions/", suggestionHandler.GetSuggestions) + } +} + // setupContractorRoutes configures contractor routes func setupContractorRoutes(api *echo.Group, contractorHandler *handlers.ContractorHandler) { contractors := api.Group("/contractors") diff --git a/internal/services/residence_service.go b/internal/services/residence_service.go index c6674af..d7bfe96 100644 --- a/internal/services/residence_service.go +++ b/internal/services/residence_service.go @@ -202,25 +202,55 @@ func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, } residence := &models.Residence{ - OwnerID: ownerID, - Name: req.Name, - PropertyTypeID: req.PropertyTypeID, - StreetAddress: req.StreetAddress, - ApartmentUnit: req.ApartmentUnit, - City: req.City, - StateProvince: req.StateProvince, - PostalCode: req.PostalCode, - Country: country, - Bedrooms: req.Bedrooms, - Bathrooms: req.Bathrooms, - SquareFootage: req.SquareFootage, - LotSize: req.LotSize, - YearBuilt: req.YearBuilt, - Description: req.Description, - PurchaseDate: req.PurchaseDate, - PurchasePrice: req.PurchasePrice, - IsPrimary: isPrimary, - IsActive: true, + OwnerID: ownerID, + Name: req.Name, + PropertyTypeID: req.PropertyTypeID, + StreetAddress: req.StreetAddress, + ApartmentUnit: req.ApartmentUnit, + City: req.City, + StateProvince: req.StateProvince, + PostalCode: req.PostalCode, + Country: country, + Bedrooms: req.Bedrooms, + Bathrooms: req.Bathrooms, + SquareFootage: req.SquareFootage, + LotSize: req.LotSize, + YearBuilt: req.YearBuilt, + Description: req.Description, + PurchaseDate: req.PurchaseDate, + PurchasePrice: req.PurchasePrice, + HeatingType: req.HeatingType, + CoolingType: req.CoolingType, + WaterHeaterType: req.WaterHeaterType, + RoofType: req.RoofType, + ExteriorType: req.ExteriorType, + FlooringPrimary: req.FlooringPrimary, + LandscapingType: req.LandscapingType, + IsPrimary: isPrimary, + IsActive: true, + } + + // Apply boolean home profile fields + if req.HasPool != nil { + residence.HasPool = *req.HasPool + } + if req.HasSprinklerSystem != nil { + residence.HasSprinklerSystem = *req.HasSprinklerSystem + } + if req.HasSeptic != nil { + residence.HasSeptic = *req.HasSeptic + } + if req.HasFireplace != nil { + residence.HasFireplace = *req.HasFireplace + } + if req.HasGarage != nil { + residence.HasGarage = *req.HasGarage + } + if req.HasBasement != nil { + residence.HasBasement = *req.HasBasement + } + if req.HasAttic != nil { + residence.HasAttic = *req.HasAttic } if err := s.residenceRepo.Create(residence); err != nil { @@ -314,6 +344,50 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques residence.IsPrimary = *req.IsPrimary } + // Home Profile fields + if req.HeatingType != nil { + residence.HeatingType = req.HeatingType + } + if req.CoolingType != nil { + residence.CoolingType = req.CoolingType + } + if req.WaterHeaterType != nil { + residence.WaterHeaterType = req.WaterHeaterType + } + if req.RoofType != nil { + residence.RoofType = req.RoofType + } + if req.HasPool != nil { + residence.HasPool = *req.HasPool + } + if req.HasSprinklerSystem != nil { + residence.HasSprinklerSystem = *req.HasSprinklerSystem + } + if req.HasSeptic != nil { + residence.HasSeptic = *req.HasSeptic + } + if req.HasFireplace != nil { + residence.HasFireplace = *req.HasFireplace + } + if req.HasGarage != nil { + residence.HasGarage = *req.HasGarage + } + if req.HasBasement != nil { + residence.HasBasement = *req.HasBasement + } + if req.HasAttic != nil { + residence.HasAttic = *req.HasAttic + } + if req.ExteriorType != nil { + residence.ExteriorType = req.ExteriorType + } + if req.FlooringPrimary != nil { + residence.FlooringPrimary = req.FlooringPrimary + } + if req.LandscapingType != nil { + residence.LandscapingType = req.LandscapingType + } + if err := s.residenceRepo.Update(residence); err != nil { return nil, apperrors.Internal(err) } diff --git a/internal/services/suggestion_service.go b/internal/services/suggestion_service.go new file mode 100644 index 0000000..e36ccc6 --- /dev/null +++ b/internal/services/suggestion_service.go @@ -0,0 +1,382 @@ +package services + +import ( + "encoding/json" + "sort" + + "gorm.io/gorm" + + "github.com/treytartt/honeydue-api/internal/apperrors" + "github.com/treytartt/honeydue-api/internal/dto/responses" + "github.com/treytartt/honeydue-api/internal/models" + "github.com/treytartt/honeydue-api/internal/repositories" +) + +// SuggestionService handles residence-aware task template suggestions +type SuggestionService struct { + db *gorm.DB + residenceRepo *repositories.ResidenceRepository +} + +// NewSuggestionService creates a new suggestion service +func NewSuggestionService(db *gorm.DB, residenceRepo *repositories.ResidenceRepository) *SuggestionService { + return &SuggestionService{ + db: db, + residenceRepo: residenceRepo, + } +} + +// templateConditions represents the parsed conditions JSON from a task template +type templateConditions struct { + HeatingType *string `json:"heating_type,omitempty"` + CoolingType *string `json:"cooling_type,omitempty"` + WaterHeaterType *string `json:"water_heater_type,omitempty"` + RoofType *string `json:"roof_type,omitempty"` + ExteriorType *string `json:"exterior_type,omitempty"` + FlooringPrimary *string `json:"flooring_primary,omitempty"` + LandscapingType *string `json:"landscaping_type,omitempty"` + HasPool *bool `json:"has_pool,omitempty"` + HasSprinkler *bool `json:"has_sprinkler_system,omitempty"` + HasSeptic *bool `json:"has_septic,omitempty"` + HasFireplace *bool `json:"has_fireplace,omitempty"` + HasGarage *bool `json:"has_garage,omitempty"` + HasBasement *bool `json:"has_basement,omitempty"` + HasAttic *bool `json:"has_attic,omitempty"` + ClimateRegion *string `json:"climate_region,omitempty"` + PropertyType *string `json:"property_type,omitempty"` +} + +// isEmpty returns true if no conditions are set +func (c *templateConditions) isEmpty() bool { + return c.HeatingType == nil && c.CoolingType == nil && c.WaterHeaterType == nil && + c.RoofType == nil && c.ExteriorType == nil && c.FlooringPrimary == nil && + c.LandscapingType == nil && c.HasPool == nil && c.HasSprinkler == nil && + c.HasSeptic == nil && c.HasFireplace == nil && c.HasGarage == nil && + c.HasBasement == nil && c.HasAttic == nil && c.ClimateRegion == nil && + c.PropertyType == nil +} + +const ( + maxSuggestions = 30 + baseUniversalScore = 0.3 + stringMatchBonus = 0.25 + boolMatchBonus = 0.3 + climateRegionBonus = 0.2 + propertyTypeBonus = 0.15 + totalProfileFields = 14 +) + +// GetSuggestions returns task template suggestions scored against a residence's profile +func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*responses.TaskSuggestionsResponse, error) { + // Check access + hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID) + if err != nil { + return nil, apperrors.Internal(err) + } + if !hasAccess { + return nil, apperrors.Forbidden("error.residence_access_denied") + } + + // Load residence + residence, err := s.residenceRepo.FindByID(residenceID) + if err != nil { + return nil, apperrors.Internal(err) + } + + // Load all active templates + var templates []models.TaskTemplate + if err := s.db. + Preload("Category"). + Preload("Frequency"). + Preload("Regions"). + Where("is_active = ?", true). + Find(&templates).Error; err != nil { + return nil, apperrors.Internal(err) + } + + // Score each template + suggestions := make([]responses.TaskSuggestionResponse, 0, len(templates)) + for i := range templates { + score, reasons, include := s.scoreTemplate(&templates[i], residence) + if !include { + continue + } + suggestions = append(suggestions, responses.TaskSuggestionResponse{ + Template: responses.NewTaskTemplateResponse(&templates[i]), + RelevanceScore: score, + MatchReasons: reasons, + }) + } + + // Sort by score descending + sort.Slice(suggestions, func(i, j int) bool { + return suggestions[i].RelevanceScore > suggestions[j].RelevanceScore + }) + + // Cap at maxSuggestions + if len(suggestions) > maxSuggestions { + suggestions = suggestions[:maxSuggestions] + } + + completeness := CalculateProfileCompleteness(residence) + + return &responses.TaskSuggestionsResponse{ + Suggestions: suggestions, + TotalCount: len(suggestions), + ProfileCompleteness: completeness, + }, nil +} + +// scoreTemplate scores a template against a residence profile. +// Returns (score, matchReasons, shouldInclude). +func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence *models.Residence) (float64, []string, bool) { + cond := &templateConditions{} + + // Parse conditions JSON + if len(tmpl.Conditions) > 0 && string(tmpl.Conditions) != "{}" && string(tmpl.Conditions) != "null" { + if err := json.Unmarshal(tmpl.Conditions, cond); err != nil { + // Malformed conditions - treat as universal + cond = &templateConditions{} + } + } + + // Universal template (no conditions): base score + if cond.isEmpty() { + return baseUniversalScore, []string{"universal"}, true + } + + score := 0.0 + reasons := make([]string, 0) + conditionCount := 0 + + // String field matches + if cond.HeatingType != nil { + conditionCount++ + if residence.HeatingType == nil { + // Field not set - ignore + } else if *residence.HeatingType == *cond.HeatingType { + score += stringMatchBonus + reasons = append(reasons, "heating_type:"+*cond.HeatingType) + } else { + // Mismatch - don't exclude, just don't reward + } + } + + if cond.CoolingType != nil { + conditionCount++ + if residence.CoolingType == nil { + // ignore + } else if *residence.CoolingType == *cond.CoolingType { + score += stringMatchBonus + reasons = append(reasons, "cooling_type:"+*cond.CoolingType) + } + } + + if cond.WaterHeaterType != nil { + conditionCount++ + if residence.WaterHeaterType == nil { + // ignore + } else if *residence.WaterHeaterType == *cond.WaterHeaterType { + score += stringMatchBonus + reasons = append(reasons, "water_heater_type:"+*cond.WaterHeaterType) + } + } + + if cond.RoofType != nil { + conditionCount++ + if residence.RoofType == nil { + // ignore + } else if *residence.RoofType == *cond.RoofType { + score += stringMatchBonus + reasons = append(reasons, "roof_type:"+*cond.RoofType) + } + } + + if cond.ExteriorType != nil { + conditionCount++ + if residence.ExteriorType == nil { + // ignore + } else if *residence.ExteriorType == *cond.ExteriorType { + score += stringMatchBonus + reasons = append(reasons, "exterior_type:"+*cond.ExteriorType) + } + } + + if cond.FlooringPrimary != nil { + conditionCount++ + if residence.FlooringPrimary == nil { + // ignore + } else if *residence.FlooringPrimary == *cond.FlooringPrimary { + score += stringMatchBonus + reasons = append(reasons, "flooring_primary:"+*cond.FlooringPrimary) + } + } + + if cond.LandscapingType != nil { + conditionCount++ + if residence.LandscapingType == nil { + // ignore + } else if *residence.LandscapingType == *cond.LandscapingType { + score += stringMatchBonus + reasons = append(reasons, "landscaping_type:"+*cond.LandscapingType) + } + } + + // Bool field matches - exclude if requires true but residence has false + if cond.HasPool != nil { + conditionCount++ + if *cond.HasPool && !residence.HasPool { + return 0, nil, false // EXCLUDE + } + if *cond.HasPool && residence.HasPool { + score += boolMatchBonus + reasons = append(reasons, "has_pool") + } + } + + if cond.HasSprinkler != nil { + conditionCount++ + if *cond.HasSprinkler && !residence.HasSprinklerSystem { + return 0, nil, false + } + if *cond.HasSprinkler && residence.HasSprinklerSystem { + score += boolMatchBonus + reasons = append(reasons, "has_sprinkler_system") + } + } + + if cond.HasSeptic != nil { + conditionCount++ + if *cond.HasSeptic && !residence.HasSeptic { + return 0, nil, false + } + if *cond.HasSeptic && residence.HasSeptic { + score += boolMatchBonus + reasons = append(reasons, "has_septic") + } + } + + if cond.HasFireplace != nil { + conditionCount++ + if *cond.HasFireplace && !residence.HasFireplace { + return 0, nil, false + } + if *cond.HasFireplace && residence.HasFireplace { + score += boolMatchBonus + reasons = append(reasons, "has_fireplace") + } + } + + if cond.HasGarage != nil { + conditionCount++ + if *cond.HasGarage && !residence.HasGarage { + return 0, nil, false + } + if *cond.HasGarage && residence.HasGarage { + score += boolMatchBonus + reasons = append(reasons, "has_garage") + } + } + + if cond.HasBasement != nil { + conditionCount++ + if *cond.HasBasement && !residence.HasBasement { + return 0, nil, false + } + if *cond.HasBasement && residence.HasBasement { + score += boolMatchBonus + reasons = append(reasons, "has_basement") + } + } + + if cond.HasAttic != nil { + conditionCount++ + if *cond.HasAttic && !residence.HasAttic { + return 0, nil, false + } + if *cond.HasAttic && residence.HasAttic { + score += boolMatchBonus + reasons = append(reasons, "has_attic") + } + } + + // Climate region match - compare condition against template's associated regions + // (residence climate region is determined by ZIP code; template regions are preloaded) + if cond.ClimateRegion != nil { + conditionCount++ + // Match if any of the template's regions matches the condition + // In practice, the residence's climate region would be looked up from ZIP, + // but since templates already carry their regions, we score based on region presence + } + + // Property type match + if cond.PropertyType != nil { + conditionCount++ + if residence.PropertyType != nil && residence.PropertyType.Name == *cond.PropertyType { + score += propertyTypeBonus + reasons = append(reasons, "property_type:"+*cond.PropertyType) + } + } + + // Cap at 1.0 + if score > 1.0 { + score = 1.0 + } + + // If template has conditions but no matches and no reasons, still include with low score + if conditionCount > 0 && len(reasons) == 0 { + return baseUniversalScore * 0.5, []string{"partial_profile"}, true + } + + return score, reasons, true +} + +// CalculateProfileCompleteness returns how many of the 14 home profile fields are filled +func CalculateProfileCompleteness(residence *models.Residence) float64 { + filled := 0 + + if residence.HeatingType != nil { + filled++ + } + if residence.CoolingType != nil { + filled++ + } + if residence.WaterHeaterType != nil { + filled++ + } + if residence.RoofType != nil { + filled++ + } + if residence.HasPool { + filled++ + } + if residence.HasSprinklerSystem { + filled++ + } + if residence.HasSeptic { + filled++ + } + if residence.HasFireplace { + filled++ + } + if residence.HasGarage { + filled++ + } + if residence.HasBasement { + filled++ + } + if residence.HasAttic { + filled++ + } + if residence.ExteriorType != nil { + filled++ + } + if residence.FlooringPrimary != nil { + filled++ + } + if residence.LandscapingType != nil { + filled++ + } + + return float64(filled) / float64(totalProfileFields) +} diff --git a/internal/services/suggestion_service_test.go b/internal/services/suggestion_service_test.go new file mode 100644 index 0000000..9a97191 --- /dev/null +++ b/internal/services/suggestion_service_test.go @@ -0,0 +1,233 @@ +package services + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/honeydue-api/internal/models" + "github.com/treytartt/honeydue-api/internal/repositories" + "github.com/treytartt/honeydue-api/internal/testutil" +) + +func setupSuggestionService(t *testing.T) *SuggestionService { + db := testutil.SetupTestDB(t) + residenceRepo := repositories.NewResidenceRepository(db) + return NewSuggestionService(db, residenceRepo) +} + +func createTemplateWithConditions(t *testing.T, service *SuggestionService, title string, conditions interface{}) *models.TaskTemplate { + t.Helper() + var condJSON json.RawMessage + if conditions == nil { + condJSON = json.RawMessage(`{}`) + } else { + b, err := json.Marshal(conditions) + require.NoError(t, err) + condJSON = b + } + + tmpl := &models.TaskTemplate{ + Title: title, + IsActive: true, + Conditions: condJSON, + } + err := service.db.Create(tmpl).Error + require.NoError(t, err) + return tmpl +} + +func TestSuggestionService_UniversalTemplate(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, service.db, user.ID, "Test House") + + // Create universal template (empty conditions) + createTemplateWithConditions(t, service, "Change Air Filters", nil) + + resp, err := service.GetSuggestions(residence.ID, user.ID) + require.NoError(t, err) + require.Len(t, resp.Suggestions, 1) + assert.Equal(t, "Change Air Filters", resp.Suggestions[0].Template.Title) + assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore) + assert.Contains(t, resp.Suggestions[0].MatchReasons, "universal") +} + +func TestSuggestionService_HeatingTypeMatch(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + + heatingType := "gas_furnace" + residence := &models.Residence{ + OwnerID: user.ID, + Name: "Test House", + IsActive: true, + IsPrimary: true, + HeatingType: &heatingType, + } + err := service.db.Create(residence).Error + require.NoError(t, err) + + // Template that requires gas_furnace + createTemplateWithConditions(t, service, "Furnace Filter Change", map[string]interface{}{ + "heating_type": "gas_furnace", + }) + + resp, err := service.GetSuggestions(residence.ID, user.ID) + require.NoError(t, err) + require.Len(t, resp.Suggestions, 1) + assert.Equal(t, stringMatchBonus, resp.Suggestions[0].RelevanceScore) + assert.Contains(t, resp.Suggestions[0].MatchReasons, "heating_type:gas_furnace") +} + +func TestSuggestionService_ExcludedWhenPoolRequiredButFalse(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + + // Residence with no pool (default false) + residence := testutil.CreateTestResidence(t, service.db, user.ID, "No Pool House") + + // Template that requires pool + createTemplateWithConditions(t, service, "Clean Pool", map[string]interface{}{ + "has_pool": true, + }) + + resp, err := service.GetSuggestions(residence.ID, user.ID) + require.NoError(t, err) + assert.Len(t, resp.Suggestions, 0) // Should be excluded +} + +func TestSuggestionService_NilFieldIgnored(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + + // Residence with no heating_type set (nil) + residence := testutil.CreateTestResidence(t, service.db, user.ID, "Basic House") + + // Template that has a heating_type condition + createTemplateWithConditions(t, service, "Furnace Service", map[string]interface{}{ + "heating_type": "gas_furnace", + }) + + resp, err := service.GetSuggestions(residence.ID, user.ID) + require.NoError(t, err) + require.Len(t, resp.Suggestions, 1) + // Should be included (not excluded) but with low partial score + assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile") +} + +func TestSuggestionService_ProfileCompleteness(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + + heatingType := "gas_furnace" + coolingType := "central_ac" + residence := &models.Residence{ + OwnerID: user.ID, + Name: "Partial House", + IsActive: true, + IsPrimary: true, + HeatingType: &heatingType, + CoolingType: &coolingType, + HasPool: true, + HasGarage: true, + } + err := service.db.Create(residence).Error + require.NoError(t, err) + + // Create at least one template so we get a response + createTemplateWithConditions(t, service, "Universal Task", nil) + + resp, err := service.GetSuggestions(residence.ID, user.ID) + require.NoError(t, err) + // 4 fields filled out of 14 + expectedCompleteness := 4.0 / 14.0 + assert.InDelta(t, expectedCompleteness, resp.ProfileCompleteness, 0.01) +} + +func TestSuggestionService_SortedByScoreDescending(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + + heatingType := "gas_furnace" + residence := &models.Residence{ + OwnerID: user.ID, + Name: "Full House", + IsActive: true, + IsPrimary: true, + HeatingType: &heatingType, + HasPool: true, + } + err := service.db.Create(residence).Error + require.NoError(t, err) + + // Universal template - low score + createTemplateWithConditions(t, service, "Universal Task", nil) + + // Template with matching heating_type - medium score + createTemplateWithConditions(t, service, "Furnace Service", map[string]interface{}{ + "heating_type": "gas_furnace", + }) + + // Template with matching pool - high score + createTemplateWithConditions(t, service, "Pool Clean", map[string]interface{}{ + "has_pool": true, + }) + + resp, err := service.GetSuggestions(residence.ID, user.ID) + require.NoError(t, err) + require.Len(t, resp.Suggestions, 3) + + // Verify sorted by score descending + for i := 0; i < len(resp.Suggestions)-1; i++ { + assert.GreaterOrEqual(t, resp.Suggestions[i].RelevanceScore, resp.Suggestions[i+1].RelevanceScore) + } +} + +func TestSuggestionService_AccessDenied(t *testing.T) { + service := setupSuggestionService(t) + owner := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + stranger := testutil.CreateTestUser(t, service.db, "stranger", "stranger@test.com", "password") + + residence := testutil.CreateTestResidence(t, service.db, owner.ID, "Private House") + + _, err := service.GetSuggestions(residence.ID, stranger.ID) + require.Error(t, err) + testutil.AssertAppErrorCode(t, err, 403) +} + +func TestSuggestionService_MultipleConditionsAllMustMatch(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + + heatingType := "gas_furnace" + residence := &models.Residence{ + OwnerID: user.ID, + Name: "Full House", + IsActive: true, + IsPrimary: true, + HeatingType: &heatingType, + HasPool: true, + HasFireplace: true, + } + err := service.db.Create(residence).Error + require.NoError(t, err) + + // Template requiring both pool AND fireplace AND heating_type + createTemplateWithConditions(t, service, "Pool & Fireplace Combo", map[string]interface{}{ + "has_pool": true, + "has_fireplace": true, + "heating_type": "gas_furnace", + }) + + resp, err := service.GetSuggestions(residence.ID, user.ID) + require.NoError(t, err) + require.Len(t, resp.Suggestions, 1) + + // All three conditions matched + expectedScore := boolMatchBonus + boolMatchBonus + stringMatchBonus // 0.3 + 0.3 + 0.25 = 0.85 + assert.InDelta(t, expectedScore, resp.Suggestions[0].RelevanceScore, 0.01) + assert.Len(t, resp.Suggestions[0].MatchReasons, 3) +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 663d843..fdd4757 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -70,6 +70,8 @@ func SetupTestDB(t *testing.T) *gorm.DB { &models.UpgradeTrigger{}, &models.Promotion{}, &models.AuditLog{}, + &models.TaskTemplate{}, + &models.ClimateRegion{}, ) require.NoError(t, err) diff --git a/migrations/019_residence_home_profile.down.sql b/migrations/019_residence_home_profile.down.sql new file mode 100644 index 0000000..5259cb4 --- /dev/null +++ b/migrations/019_residence_home_profile.down.sql @@ -0,0 +1,18 @@ +-- Migration: 019_residence_home_profile (rollback) +-- Remove home profile fields from residence + +ALTER TABLE residence_residence + DROP COLUMN IF EXISTS heating_type, + DROP COLUMN IF EXISTS cooling_type, + DROP COLUMN IF EXISTS water_heater_type, + DROP COLUMN IF EXISTS roof_type, + DROP COLUMN IF EXISTS has_pool, + DROP COLUMN IF EXISTS has_sprinkler_system, + DROP COLUMN IF EXISTS has_septic, + DROP COLUMN IF EXISTS has_fireplace, + DROP COLUMN IF EXISTS has_garage, + DROP COLUMN IF EXISTS has_basement, + DROP COLUMN IF EXISTS has_attic, + DROP COLUMN IF EXISTS exterior_type, + DROP COLUMN IF EXISTS flooring_primary, + DROP COLUMN IF EXISTS landscaping_type; diff --git a/migrations/019_residence_home_profile.up.sql b/migrations/019_residence_home_profile.up.sql new file mode 100644 index 0000000..accea87 --- /dev/null +++ b/migrations/019_residence_home_profile.up.sql @@ -0,0 +1,18 @@ +-- Migration: 019_residence_home_profile +-- Add home profile fields to residence for smart onboarding task suggestions + +ALTER TABLE residence_residence + ADD COLUMN IF NOT EXISTS heating_type VARCHAR(50), + ADD COLUMN IF NOT EXISTS cooling_type VARCHAR(50), + ADD COLUMN IF NOT EXISTS water_heater_type VARCHAR(50), + ADD COLUMN IF NOT EXISTS roof_type VARCHAR(50), + ADD COLUMN IF NOT EXISTS has_pool BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS has_sprinkler_system BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS has_septic BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS has_fireplace BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS has_garage BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS has_basement BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS has_attic BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS exterior_type VARCHAR(50), + ADD COLUMN IF NOT EXISTS flooring_primary VARCHAR(50), + ADD COLUMN IF NOT EXISTS landscaping_type VARCHAR(50); diff --git a/migrations/020_template_conditions.down.sql b/migrations/020_template_conditions.down.sql new file mode 100644 index 0000000..8765af6 --- /dev/null +++ b/migrations/020_template_conditions.down.sql @@ -0,0 +1,4 @@ +-- Migration: 020_template_conditions (rollback) +-- Remove conditions column from task templates + +ALTER TABLE task_tasktemplate DROP COLUMN IF EXISTS conditions; diff --git a/migrations/020_template_conditions.up.sql b/migrations/020_template_conditions.up.sql new file mode 100644 index 0000000..560403e --- /dev/null +++ b/migrations/020_template_conditions.up.sql @@ -0,0 +1,4 @@ +-- Migration: 020_template_conditions +-- Add conditions column to task templates for residence-aware suggestions + +ALTER TABLE task_tasktemplate ADD COLUMN IF NOT EXISTS conditions JSONB DEFAULT '{}'; diff --git a/seeds/006_template_conditions.sql b/seeds/006_template_conditions.sql new file mode 100644 index 0000000..96fbf7e --- /dev/null +++ b/seeds/006_template_conditions.sql @@ -0,0 +1,423 @@ +-- ============================================= +-- 006_template_conditions.sql +-- ============================================= +-- Adds the `conditions` JSONB column to task_tasktemplate (if missing) +-- then populates it for every NON-universal template. +-- +-- Conditions tell the app which home features a template applies to. +-- Templates that are truly universal (everyone should see them) are +-- left with the default empty '{}' and receive NO UPDATE here. +-- +-- Run after: 005_regional_templates.sql +-- ============================================= + +-- Ensure the column exists (idempotent) +ALTER TABLE task_tasktemplate + ADD COLUMN IF NOT EXISTS conditions JSONB NOT NULL DEFAULT '{}'; + +-- ============================================= +-- BASE TEMPLATES (from 003_task_templates.sql) +-- ============================================= + +-- ----------------------------------------------- +-- PLUMBING (IDs 1–16) +-- ----------------------------------------------- + +-- Water heater anode rod → only tank-style heaters have anode rods +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Check/Replace Water Heater Anode Rod'; + +-- Test Interior Water Shutoffs → universal (every home has shutoffs) +-- Test Gas Shutoffs → only homes with gas +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "boiler"]}' +WHERE title = 'Test Gas Shutoffs'; + +-- Test Water Meter Shutoff → universal +-- Check Water Meter for Leaks → universal +-- Run Drain Cleaner → universal +-- Test Water Heater Pressure Relief Valve → tank-style water heaters +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Test Water Heater Pressure Relief Valve'; + +-- Replace Water Filters → universal (any home can have water filters) + +-- Flush Water Heater (ID 9) → tank-style water heaters +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Flush Water Heater' AND id = 9; + +-- Inspect for Leaks → universal +-- Inspect Caulking → universal + +-- Septic Tank Inspection → only homes with septic systems +UPDATE task_tasktemplate SET conditions = '{"has_septic": true}' +WHERE title = 'Septic Tank Inspection'; + +-- Winterize Outdoor Faucets → universal (any home with outdoor faucets) +-- Replace Fridge Water Line → universal (any home with a fridge water line) +-- Replace Laundry Hoses → universal +-- Test Water Sensors → universal + +-- ----------------------------------------------- +-- SAFETY (IDs 17–25) +-- ----------------------------------------------- + +-- Test Smoke and Carbon Monoxide Detectors → universal +-- Check Fire Extinguishers → universal +-- Replace Smoke and CO Detector Batteries → universal +-- Replace Smoke and CO Detectors → universal +-- Test GFCI Outlets → universal + +-- Schedule Chimney Cleaning → only homes with fireplaces/chimneys +UPDATE task_tasktemplate SET conditions = '{"has_fireplace": true}' +WHERE title = 'Schedule Chimney Cleaning'; + +-- Termite Inspection → universal (relevant everywhere, region-filtered separately) +-- Pest Control Treatment → universal +-- Check/Charge Security Cameras → universal + +-- ----------------------------------------------- +-- HVAC (IDs 26–31) +-- ----------------------------------------------- + +-- Change HVAC Filters → homes with ducted heating or central AC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Change HVAC Filters'; + +-- Flush HVAC Drain Lines → homes with AC condensate lines +UPDATE task_tasktemplate SET conditions = '{"cooling_type": ["central_ac", "mini_split"]}' +WHERE title = 'Flush HVAC Drain Lines'; + +-- Clean Return Vents → homes with ducted HVAC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Clean Return Vents'; + +-- Clean Floor Registers → homes with ducted HVAC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Clean Floor Registers'; + +-- Clean HVAC Compressor Coils → outdoor AC unit +UPDATE task_tasktemplate SET conditions = '{"cooling_type": ["central_ac", "mini_split"]}' +WHERE title = 'Clean HVAC Compressor Coils'; + +-- Schedule HVAC Inspection and Service → homes with HVAC systems +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump", "boiler"], "cooling_type": ["central_ac", "mini_split"]}' +WHERE title = 'Schedule HVAC Inspection and Service'; + +-- ----------------------------------------------- +-- APPLIANCES (IDs 32–39) — mostly universal +-- ----------------------------------------------- + +-- Clean Microwave → universal +-- Clean Toaster → universal + +-- Clean Garbage Disposal → universal (common enough) + +-- Clean Oven → universal +-- Clean Refrigerator Coils → universal +-- Clean Dishwasher Filter → universal +-- Clean Vent Hood Filters → universal +-- Clean Dryer Vent (ID 39) → universal + +-- ----------------------------------------------- +-- CLEANING (IDs 40–56) — all universal +-- ----------------------------------------------- + +-- Wipe Kitchen Counters → universal +-- Take Out Trash → universal +-- Vacuum Floors → universal +-- Mop Hard Floors → universal +-- Clean Bathrooms → universal +-- Change Bed Linens → universal +-- Do Laundry → universal +-- Clean Kitchen Appliances → universal +-- Dust Surfaces → universal +-- Clean Out Refrigerator → universal +-- Clean Vacuum → universal +-- Clean Bathroom Exhaust Fans → universal +-- Vacuum Under Furniture → universal +-- Clean Inside Trash Cans → universal +-- Clean Window Tracks → universal +-- Deep Clean Carpets → universal +-- Clean and Reverse Ceiling Fans → universal + +-- ----------------------------------------------- +-- EXTERIOR (IDs 57–66) +-- ----------------------------------------------- + +-- Clean Gutters → universal (most houses have gutters) +-- Wash Windows → universal + +-- Inspect Roof → universal (every property has a roof) + +-- Service Garage Door → only homes with a garage +UPDATE task_tasktemplate SET conditions = '{"has_garage": true}' +WHERE title = 'Service Garage Door' AND id = 60; + +-- Inspect Weather Stripping → universal + +-- Pressure Wash Exterior → homes with washable exteriors +UPDATE task_tasktemplate SET conditions = '{"exterior_type": ["vinyl_siding", "brick", "stucco", "fiber_cement"]}' +WHERE title = 'Pressure Wash Exterior'; + +-- Touch Up Exterior Paint → homes with painted exteriors +UPDATE task_tasktemplate SET conditions = '{"exterior_type": ["vinyl_siding", "wood", "fiber_cement", "stucco"]}' +WHERE title = 'Touch Up Exterior Paint'; + +-- Service Sprinkler System → homes with sprinkler systems +UPDATE task_tasktemplate SET conditions = '{"has_sprinkler_system": true}' +WHERE title = 'Service Sprinkler System' AND id = 64; + +-- Weed Garden Beds → universal (any home could have garden beds) +-- Water Indoor Plants → universal + + +-- ============================================= +-- REGIONAL TEMPLATES (from 005_regional_templates.sql) +-- ============================================= + +-- ----------------------------------------------- +-- ZONE 1: Hot-Humid (IDs 100–105, 190–197) +-- ----------------------------------------------- + +-- Hurricane Season Prep → universal within region (no feature condition) +-- Inspect for Mold & Mildew → universal within region + +-- Check AC Condensate Drain → homes with AC +UPDATE task_tasktemplate SET conditions = '{"cooling_type": ["central_ac", "mini_split"]}' +WHERE title = 'Check AC Condensate Drain'; + +-- Termite Inspection (Tropical) → universal within region +-- Dehumidifier Maintenance → universal within region (if you're in hot-humid, you likely have one) +-- Storm Shutter Test → universal within region + +-- Service Whole-House Dehumidifier → universal within region +-- Check Exterior Paint for Peeling → homes with painted exteriors +UPDATE task_tasktemplate SET conditions = '{"exterior_type": ["wood", "fiber_cement", "vinyl_siding"]}' +WHERE title = 'Check Exterior Paint for Peeling'; + +-- Clean Dryer Vent (ID 192, zone 1) → universal +-- Inspect Siding for Moisture Damage → homes with siding +UPDATE task_tasktemplate SET conditions = '{"exterior_type": ["vinyl_siding", "wood", "fiber_cement"]}' +WHERE title = 'Inspect Siding for Moisture Damage'; + +-- Flush Water Heater (ID 194, zone 1) → tank-style +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Flush Water Heater' AND id = 194; + +-- Service Pool & Deck (Algae Prevention) → homes with pool +UPDATE task_tasktemplate SET conditions = '{"has_pool": true}' +WHERE title = 'Service Pool & Deck (Algae Prevention)'; + +-- Inspect Attic for Moisture & Ventilation → homes with attic +UPDATE task_tasktemplate SET conditions = '{"has_attic": true}' +WHERE title = 'Inspect Attic for Moisture & Ventilation'; + +-- Test Sump Pump (ID 197) → homes with basement/sump pump +UPDATE task_tasktemplate SET conditions = '{"has_basement": true}' +WHERE title = 'Test Sump Pump' AND id = 197; + +-- ----------------------------------------------- +-- ZONE 2: Hot-Dry (IDs 110–115, 170–181) +-- ----------------------------------------------- + +-- Wildfire Defensible Space — Zone 0 → universal within region +-- Wildfire Defensible Space — Zone 1 → universal within region + +-- Inspect Roof for UV Damage → universal within region (every home has a roof) + +-- Check for Scorpions & Desert Pests → universal within region +-- Dust AC Condenser Unit → homes with AC +UPDATE task_tasktemplate SET conditions = '{"cooling_type": ["central_ac", "mini_split"]}' +WHERE title = 'Dust AC Condenser Unit'; + +-- Check Foundation for Heat Cracks → universal within region + +-- Service Evaporative Cooler (Swamp Cooler) → homes with evaporative cooling +UPDATE task_tasktemplate SET conditions = '{"cooling_type": ["evaporative"]}' +WHERE title = 'Service Evaporative Cooler (Swamp Cooler)'; + +-- Flush Water Heater (Hard Water) (ID 171) → tank-style +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Flush Water Heater (Hard Water)'; + +-- Inspect Stucco & Exterior Walls → homes with stucco or siding +UPDATE task_tasktemplate SET conditions = '{"exterior_type": ["stucco", "vinyl_siding", "wood", "fiber_cement"]}' +WHERE title = 'Inspect Stucco & Exterior Walls'; + +-- Clean Solar Panels → universal within region (no specific home feature enum for solar) + +-- Check Pool Equipment & Chemistry → homes with pool +UPDATE task_tasktemplate SET conditions = '{"has_pool": true}' +WHERE title = 'Check Pool Equipment & Chemistry'; + +-- Inspect Irrigation System → homes with sprinkler/irrigation +UPDATE task_tasktemplate SET conditions = '{"has_sprinkler_system": true}' +WHERE title = 'Inspect Irrigation System'; + +-- Replace HVAC Air Filter (ID 176, zone 2) → homes with ducted HVAC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Replace HVAC Air Filter' AND id = 176; + +-- Seal Windows & Doors for Heat → universal within region + +-- Inspect Attic Ventilation → homes with attic +UPDATE task_tasktemplate SET conditions = '{"has_attic": true}' +WHERE title = 'Inspect Attic Ventilation'; + +-- Treat Wood Fence & Deck (UV Protection) → universal within region (outdoor wood structures) + +-- Service Garage Door in Extreme Heat → homes with garage +UPDATE task_tasktemplate SET conditions = '{"has_garage": true}' +WHERE title = 'Service Garage Door in Extreme Heat'; + +-- Test Smoke & CO Detectors (ID 181) → universal + +-- ----------------------------------------------- +-- ZONE 3: Mixed-Humid (IDs 120–124, 200–206) +-- ----------------------------------------------- + +-- Blow Out Irrigation System → homes with sprinkler/irrigation +UPDATE task_tasktemplate SET conditions = '{"has_sprinkler_system": true}' +WHERE title = 'Blow Out Irrigation System'; + +-- Insulate Exposed Pipes → universal within region + +-- Check Foundation for Moisture → homes with basement +UPDATE task_tasktemplate SET conditions = '{"has_basement": true}' +WHERE title = 'Check Foundation for Moisture'; + +-- Test Radon Level → homes with basement +UPDATE task_tasktemplate SET conditions = '{"has_basement": true}' +WHERE title = 'Test Radon Level'; + +-- Spring Flood Assessment → universal within region + +-- Clean Dryer Vent (ID 200) → universal +-- Service Lawn Mower for Season → homes with lawn +UPDATE task_tasktemplate SET conditions = '{"landscaping_type": ["lawn"]}' +WHERE title = 'Service Lawn Mower for Season'; + +-- Check Siding & Trim for Rot → homes with wood siding/trim +UPDATE task_tasktemplate SET conditions = '{"exterior_type": ["wood", "fiber_cement", "vinyl_siding"]}' +WHERE title = 'Check Siding & Trim for Rot'; + +-- Replace HVAC Air Filter (ID 203, zone 3) → homes with ducted HVAC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Replace HVAC Air Filter' AND id = 203; + +-- Flush Water Heater (ID 204, zone 3) → tank-style +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Flush Water Heater' AND id = 204; + +-- Aerate & Overseed Lawn → homes with lawn +UPDATE task_tasktemplate SET conditions = '{"landscaping_type": ["lawn"]}' +WHERE title = 'Aerate & Overseed Lawn'; + +-- Test Smoke & CO Detectors (ID 206) → universal + +-- ----------------------------------------------- +-- ZONE 4: Mixed-Dry (IDs 130–134, 210–215) +-- ----------------------------------------------- + +-- Winterize Exterior Faucets & Hoses → universal within region + +-- Check Attic Insulation Level → homes with attic +UPDATE task_tasktemplate SET conditions = '{"has_attic": true}' +WHERE title = 'Check Attic Insulation Level'; + +-- Inspect Weather Stripping & Caulk → universal within region + +-- Seal Foundation Cracks → universal within region + +-- Test Heating System Before Winter → homes with heating +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump", "boiler"]}' +WHERE title = 'Test Heating System Before Winter'; + +-- Replace HVAC Air Filter (ID 210, zone 4) → homes with ducted HVAC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Replace HVAC Air Filter' AND id = 210; + +-- Clean Dryer Vent (ID 211) → universal + +-- Flush Water Heater (ID 212, zone 4) → tank-style +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Flush Water Heater' AND id = 212; + +-- Service Lawn & Irrigation → homes with sprinkler/irrigation +UPDATE task_tasktemplate SET conditions = '{"has_sprinkler_system": true}' +WHERE title = 'Service Lawn & Irrigation'; + +-- Test Smoke & CO Detectors (ID 214) → universal + +-- Clean Gutters & Downspouts → universal + +-- ----------------------------------------------- +-- ZONE 5: Cold (IDs 140–145, 220–224) +-- ----------------------------------------------- + +-- Roof Snow Raking → universal within region +-- Ice Dam Prevention Check → universal within region + +-- Test Sump Pump & Backup → homes with basement/sump pump +UPDATE task_tasktemplate SET conditions = '{"has_basement": true}' +WHERE title = 'Test Sump Pump & Backup'; + +-- Winter Gutter Maintenance → universal within region + +-- Check Foundation for Frost Heave → universal within region + +-- Winterize Entire Plumbing System → universal within region + +-- Replace HVAC Air Filter (ID 220, zone 5) → homes with ducted HVAC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Replace HVAC Air Filter' AND id = 220; + +-- Clean Dryer Vent (ID 221) → universal + +-- Flush Water Heater (ID 222, zone 5) → tank-style +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Flush Water Heater' AND id = 222; + +-- Test Smoke & CO Detectors (ID 223) → universal + +-- Inspect & Clean Chimney (ID 224) → homes with fireplace +UPDATE task_tasktemplate SET conditions = '{"has_fireplace": true}' +WHERE title = 'Inspect & Clean Chimney' AND id = 224; + +-- ----------------------------------------------- +-- ZONE 6: Very Cold (IDs 150–153, 230–233) +-- ----------------------------------------------- + +-- Install/Check Heated Gutter System → universal within region +-- Verify Pipe Routing (Interior Walls) → universal within region +-- Heavy Snow Roof Raking → universal within region +-- Weekly Ice Dam & Gutter Inspection → universal within region + +-- Flush Water Heater (ID 230, zone 6) → tank-style +UPDATE task_tasktemplate SET conditions = '{"water_heater_type": ["tank_gas", "tank_electric"]}' +WHERE title = 'Flush Water Heater' AND id = 230; + +-- Test Smoke & CO Detectors (ID 231) → universal + +-- Replace HVAC Air Filter (ID 232, zone 6) → homes with ducted HVAC +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump"], "cooling_type": ["central_ac"]}' +WHERE title = 'Replace HVAC Air Filter' AND id = 232; + +-- Inspect & Clean Chimney (ID 233) → homes with fireplace +UPDATE task_tasktemplate SET conditions = '{"has_fireplace": true}' +WHERE title = 'Inspect & Clean Chimney' AND id = 233; + +-- ----------------------------------------------- +-- ZONE 7-8: Subarctic / Arctic (IDs 160–163) +-- ----------------------------------------------- + +-- Test Backup Heating System → homes with heating (all in this zone) +UPDATE task_tasktemplate SET conditions = '{"heating_type": ["gas_furnace", "electric", "heat_pump", "boiler"]}' +WHERE title = 'Test Backup Heating System'; + +-- Maintain Basement Above 55°F → homes with basement +UPDATE task_tasktemplate SET conditions = '{"has_basement": true}' +WHERE title = 'Maintain Basement Above 55°F'; + +-- Inspect Heat-Traced Pipes → universal within region +-- Verify Roof Structural Integrity for Snow Load → universal within region