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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user