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:
Trey T
2026-03-30 09:02:03 -05:00
parent 4c9a818bd9
commit cb7080c460
16 changed files with 1347 additions and 32 deletions

View File

@@ -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

View File

@@ -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,
}

View 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"`
}

View 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)
}

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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")

View File

@@ -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)
}

View 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)
}

View 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)
}

View File

@@ -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)