Smart onboarding: residence home profile + suggestion engine
14 new optional residence fields (heating, cooling, water heater, roof, pool, sprinkler, septic, fireplace, garage, basement, attic, exterior, flooring, landscaping) with JSONB conditions on templates. Suggestion engine scores templates against home profile: string match +0.25, bool +0.3, property type +0.15, universal base 0.3. Graceful degradation from minimal to full profile info. GET /api/tasks/suggestions/?residence_id=X returns ranked templates. 54 template conditions across 44 templates in seed data. 8 suggestion service tests.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
15
internal/dto/responses/suggestion.go
Normal file
15
internal/dto/responses/suggestion.go
Normal file
@@ -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"`
|
||||
}
|
||||
50
internal/handlers/suggestion_handler.go
Normal file
50
internal/handlers/suggestion_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
382
internal/services/suggestion_service.go
Normal file
382
internal/services/suggestion_service.go
Normal file
@@ -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)
|
||||
}
|
||||
233
internal/services/suggestion_service_test.go
Normal file
233
internal/services/suggestion_service_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
18
migrations/019_residence_home_profile.down.sql
Normal file
18
migrations/019_residence_home_profile.down.sql
Normal file
@@ -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;
|
||||
18
migrations/019_residence_home_profile.up.sql
Normal file
18
migrations/019_residence_home_profile.up.sql
Normal file
@@ -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);
|
||||
4
migrations/020_template_conditions.down.sql
Normal file
4
migrations/020_template_conditions.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Migration: 020_template_conditions (rollback)
|
||||
-- Remove conditions column from task templates
|
||||
|
||||
ALTER TABLE task_tasktemplate DROP COLUMN IF EXISTS conditions;
|
||||
4
migrations/020_template_conditions.up.sql
Normal file
4
migrations/020_template_conditions.up.sql
Normal file
@@ -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 '{}';
|
||||
423
seeds/006_template_conditions.sql
Normal file
423
seeds/006_template_conditions.sql
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user