Add regional task templates API with climate zone lookup
Adds a new endpoint GET /api/tasks/templates/by-region/?zip= that resolves ZIP codes to IECC climate regions and returns relevant home maintenance task templates. Includes climate region model, region lookup service with tests, seed data for all 8 climate zones with 50+ templates, and OpenAPI spec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -131,7 +131,9 @@ func Migrate() error {
|
||||
&models.TaskPriority{},
|
||||
&models.TaskFrequency{},
|
||||
&models.ContractorSpecialty{},
|
||||
&models.TaskTemplate{}, // Task templates reference category and frequency
|
||||
&models.TaskTemplate{}, // Task templates reference category and frequency
|
||||
&models.ClimateRegion{}, // IECC climate regions for regional templates
|
||||
&models.ZipClimateRegion{}, // ZIP to climate region lookup
|
||||
|
||||
// User and auth tables
|
||||
&models.User{},
|
||||
|
||||
@@ -21,6 +21,8 @@ type TaskTemplateResponse struct {
|
||||
Tags []string `json:"tags"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RegionID *uint `json:"region_id,omitempty"`
|
||||
RegionName string `json:"region_name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -63,6 +65,11 @@ func NewTaskTemplateResponse(t *models.TaskTemplate) TaskTemplateResponse {
|
||||
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
|
||||
}
|
||||
|
||||
if len(t.Regions) > 0 {
|
||||
resp.RegionID = &t.Regions[0].ID
|
||||
resp.RegionName = t.Regions[0].Name
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,24 @@ func (h *TaskTemplateHandler) GetTemplatesByCategory(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplatesByRegion handles GET /api/tasks/templates/by-region/?state=XX or ?zip=12345
|
||||
// Returns templates specific to the user's climate region based on state abbreviation or ZIP code
|
||||
func (h *TaskTemplateHandler) GetTemplatesByRegion(c echo.Context) error {
|
||||
state := c.QueryParam("state")
|
||||
zip := c.QueryParam("zip")
|
||||
|
||||
if state == "" && zip == "" {
|
||||
return apperrors.BadRequest("error.state_or_zip_required")
|
||||
}
|
||||
|
||||
templates, err := h.templateService.GetByRegion(state, zip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplate handles GET /api/tasks/templates/:id/
|
||||
// Returns a single template by ID
|
||||
func (h *TaskTemplateHandler) GetTemplate(c echo.Context) error {
|
||||
|
||||
@@ -98,6 +98,8 @@ var specEndpointsKMPSkips = map[routeKey]bool{
|
||||
{Method: "POST", Path: "/notifications/devices/"}: true, // KMP uses /notifications/devices/register/
|
||||
{Method: "POST", Path: "/notifications/devices/unregister/"}: true, // KMP uses DELETE on device ID
|
||||
{Method: "PATCH", Path: "/notifications/preferences/"}: true, // KMP uses PUT
|
||||
// Regional templates — not yet implemented in KMP (planned)
|
||||
{Method: "GET", Path: "/tasks/templates/by-region/"}: true,
|
||||
// Stripe web-only and server-to-server endpoints — not implemented in mobile KMP
|
||||
{Method: "POST", Path: "/subscription/checkout/"}: true, // Web-only (Stripe Checkout)
|
||||
{Method: "POST", Path: "/subscription/portal/"}: true, // Web-only (Stripe Customer Portal)
|
||||
|
||||
28
internal/models/climate_region.go
Normal file
28
internal/models/climate_region.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
// ClimateRegion represents an IECC climate zone for regional task templates
|
||||
type ClimateRegion struct {
|
||||
BaseModel
|
||||
Name string `gorm:"column:name;size:100;not null;uniqueIndex" json:"name"`
|
||||
ZoneNumber int `gorm:"column:zone_number;not null;index" json:"zone_number"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ClimateRegion) TableName() string {
|
||||
return "task_climateregion"
|
||||
}
|
||||
|
||||
// ZipClimateRegion maps ZIP codes to climate regions (static lookup)
|
||||
type ZipClimateRegion struct {
|
||||
BaseModel
|
||||
ZipCode string `gorm:"column:zip_code;size:10;uniqueIndex;not null" json:"zip_code"`
|
||||
ClimateRegionID uint `gorm:"column:climate_region_id;index;not null" json:"climate_region_id"`
|
||||
ClimateRegion ClimateRegion `gorm:"foreignKey:ClimateRegionID" json:"climate_region,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ZipClimateRegion) TableName() string {
|
||||
return "task_zipclimateregion"
|
||||
}
|
||||
@@ -13,7 +13,8 @@ type TaskTemplate struct {
|
||||
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"`
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
Regions []ClimateRegion `gorm:"many2many:task_tasktemplate_regions;" json:"regions,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
|
||||
@@ -104,6 +104,20 @@ func (r *TaskTemplateRepository) Count() (int64, error) {
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetByRegion returns active templates associated with a specific climate region
|
||||
func (r *TaskTemplateRepository) GetByRegion(regionID uint) ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Preload("Regions").
|
||||
Joins("JOIN task_tasktemplate_regions ON task_tasktemplate_regions.task_template_id = task_tasktemplate.id").
|
||||
Where("task_tasktemplate_regions.climate_region_id = ? AND task_tasktemplate.is_active = ?", regionID, true).
|
||||
Order("task_tasktemplate.display_order ASC, task_tasktemplate.title ASC").
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// GetGroupedByCategory returns templates grouped by category name
|
||||
func (r *TaskTemplateRepository) GetGroupedByCategory() (map[string][]models.TaskTemplate, error) {
|
||||
templates, err := r.GetAll()
|
||||
|
||||
@@ -339,6 +339,7 @@ func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.Residence
|
||||
templates.GET("/grouped/", taskTemplateHandler.GetTemplatesGrouped)
|
||||
templates.GET("/search/", taskTemplateHandler.SearchTemplates)
|
||||
templates.GET("/by-category/:category_id/", taskTemplateHandler.GetTemplatesByCategory)
|
||||
templates.GET("/by-region/", taskTemplateHandler.GetTemplatesByRegion)
|
||||
templates.GET("/:id/", taskTemplateHandler.GetTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
157
internal/services/region_lookup.go
Normal file
157
internal/services/region_lookup.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StateToClimateRegion maps US state abbreviations to IECC climate zone IDs.
|
||||
// Some states span multiple zones — this uses the dominant zone for the most populated areas.
|
||||
var StateToClimateRegion = map[string]uint{
|
||||
// Zone 1: Hot-Humid
|
||||
"HI": 1, "FL": 1, "LA": 1,
|
||||
// Zone 2: Hot-Dry / Hot-Humid mix
|
||||
"TX": 2, "AZ": 2, "NV": 2, "NM": 2,
|
||||
// Zone 3: Mixed-Humid
|
||||
"GA": 3, "SC": 3, "AL": 3, "MS": 3, "AR": 3, "NC": 3, "TN": 3, "OK": 3, "CA": 3,
|
||||
// Zone 4: Mixed
|
||||
"VA": 4, "KY": 4, "MO": 4, "KS": 4, "DE": 4, "MD": 4, "DC": 4, "WV": 4, "OR": 4,
|
||||
// Zone 5: Cold
|
||||
"NJ": 5, "PA": 5, "CT": 5, "RI": 5, "MA": 5, "OH": 5, "IN": 5, "IL": 5,
|
||||
"IA": 5, "NE": 5, "CO": 5, "UT": 5, "WA": 5, "ID": 5, "NY": 5, "MI": 5,
|
||||
// Zone 6: Very Cold
|
||||
"WI": 6, "MN": 6, "ND": 6, "SD": 6, "MT": 6, "WY": 6, "VT": 6, "NH": 6, "ME": 6,
|
||||
// Zone 8: Arctic
|
||||
"AK": 8,
|
||||
}
|
||||
|
||||
// GetClimateRegionIDByState returns the climate region ID for a US state abbreviation.
|
||||
// Returns 0 if the state is not found.
|
||||
func GetClimateRegionIDByState(state string) uint {
|
||||
regionID, ok := StateToClimateRegion[strings.ToUpper(strings.TrimSpace(state))]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return regionID
|
||||
}
|
||||
|
||||
// ZipToState maps a US ZIP code to a state abbreviation using the 3-digit ZIP prefix.
|
||||
// Returns empty string if the ZIP is invalid or unrecognized.
|
||||
func ZipToState(zip string) string {
|
||||
zip = strings.TrimSpace(zip)
|
||||
if len(zip) < 3 {
|
||||
return ""
|
||||
}
|
||||
prefix, err := strconv.Atoi(zip[:3])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ZIP prefix → state mapping (USPS ranges)
|
||||
switch {
|
||||
case prefix >= 10 && prefix <= 27:
|
||||
return "MA"
|
||||
case prefix >= 28 && prefix <= 29:
|
||||
return "RI"
|
||||
case prefix >= 30 && prefix <= 38:
|
||||
return "NH"
|
||||
case prefix >= 39 && prefix <= 49:
|
||||
return "ME"
|
||||
case prefix >= 50 && prefix <= 59:
|
||||
return "VT"
|
||||
case prefix >= 60 && prefix <= 69:
|
||||
return "CT"
|
||||
case prefix >= 70 && prefix <= 89:
|
||||
return "NJ"
|
||||
case prefix >= 100 && prefix <= 149:
|
||||
return "NY"
|
||||
case prefix >= 150 && prefix <= 196:
|
||||
return "PA"
|
||||
case prefix >= 197 && prefix <= 199:
|
||||
return "DE"
|
||||
case prefix >= 200 && prefix <= 205:
|
||||
return "DC"
|
||||
case prefix >= 206 && prefix <= 219:
|
||||
return "MD"
|
||||
case prefix >= 220 && prefix <= 246:
|
||||
return "VA"
|
||||
case prefix >= 247 && prefix <= 268:
|
||||
return "WV"
|
||||
case prefix >= 270 && prefix <= 289:
|
||||
return "NC"
|
||||
case prefix >= 290 && prefix <= 299:
|
||||
return "SC"
|
||||
case prefix >= 300 && prefix <= 319:
|
||||
return "GA"
|
||||
case prefix >= 320 && prefix <= 349:
|
||||
return "FL"
|
||||
case prefix >= 350 && prefix <= 369:
|
||||
return "AL"
|
||||
case prefix >= 370 && prefix <= 385:
|
||||
return "TN"
|
||||
case prefix >= 386 && prefix <= 397:
|
||||
return "MS"
|
||||
case prefix >= 400 && prefix <= 427:
|
||||
return "KY"
|
||||
case prefix >= 430 && prefix <= 458:
|
||||
return "OH"
|
||||
case prefix >= 460 && prefix <= 479:
|
||||
return "IN"
|
||||
case prefix >= 480 && prefix <= 499:
|
||||
return "MI"
|
||||
case prefix >= 500 && prefix <= 528:
|
||||
return "IA"
|
||||
case prefix >= 530 && prefix <= 549:
|
||||
return "WI"
|
||||
case prefix >= 550 && prefix <= 567:
|
||||
return "MN"
|
||||
case prefix >= 570 && prefix <= 577:
|
||||
return "SD"
|
||||
case prefix >= 580 && prefix <= 588:
|
||||
return "ND"
|
||||
case prefix >= 590 && prefix <= 599:
|
||||
return "MT"
|
||||
case prefix >= 600 && prefix <= 629:
|
||||
return "IL"
|
||||
case prefix >= 630 && prefix <= 658:
|
||||
return "MO"
|
||||
case prefix >= 660 && prefix <= 679:
|
||||
return "KS"
|
||||
case prefix >= 680 && prefix <= 693:
|
||||
return "NE"
|
||||
case prefix >= 700 && prefix <= 714:
|
||||
return "LA"
|
||||
case prefix >= 716 && prefix <= 729:
|
||||
return "AR"
|
||||
case prefix >= 730 && prefix <= 749:
|
||||
return "OK"
|
||||
case prefix >= 750 && prefix <= 799:
|
||||
return "TX"
|
||||
case prefix >= 800 && prefix <= 816:
|
||||
return "CO"
|
||||
case prefix >= 820 && prefix <= 831:
|
||||
return "WY"
|
||||
case prefix >= 832 && prefix <= 838:
|
||||
return "ID"
|
||||
case prefix >= 840 && prefix <= 847:
|
||||
return "UT"
|
||||
case prefix >= 850 && prefix <= 865:
|
||||
return "AZ"
|
||||
case prefix >= 870 && prefix <= 884:
|
||||
return "NM"
|
||||
case prefix >= 889 && prefix <= 898:
|
||||
return "NV"
|
||||
case prefix >= 900 && prefix <= 966:
|
||||
return "CA"
|
||||
case prefix >= 967 && prefix <= 968:
|
||||
return "HI"
|
||||
case prefix >= 970 && prefix <= 979:
|
||||
return "OR"
|
||||
case prefix >= 980 && prefix <= 994:
|
||||
return "WA"
|
||||
case prefix >= 995 && prefix <= 999:
|
||||
return "AK"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
81
internal/services/region_lookup_test.go
Normal file
81
internal/services/region_lookup_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetClimateRegionIDByState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
expected uint
|
||||
}{
|
||||
{"Massachusetts → Cold (5)", "MA", 5},
|
||||
{"Florida → Hot-Humid (1)", "FL", 1},
|
||||
{"Arizona → Hot-Dry (2)", "AZ", 2},
|
||||
{"Alaska → Arctic (8)", "AK", 8},
|
||||
{"Minnesota → Very Cold (6)", "MN", 6},
|
||||
{"Tennessee → Mixed-Humid (3)", "TN", 3},
|
||||
{"Missouri → Mixed (4)", "MO", 4},
|
||||
{"Unknown state → 0", "XX", 0},
|
||||
{"Empty string → 0", "", 0},
|
||||
{"Lowercase input", "ma", 5},
|
||||
{"Whitespace trimmed", " TX ", 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := GetClimateRegionIDByState(tt.state)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipToState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
zip string
|
||||
expected string
|
||||
}{
|
||||
{"Boston MA", "02101", "MA"},
|
||||
{"Miami FL", "33101", "FL"},
|
||||
{"Phoenix AZ", "85001", "AZ"},
|
||||
{"Anchorage AK", "99501", "AK"},
|
||||
{"New York NY", "10001", "NY"},
|
||||
{"Chicago IL", "60601", "IL"},
|
||||
{"Houston TX", "77001", "TX"},
|
||||
{"Denver CO", "80201", "CO"},
|
||||
{"DC", "20001", "DC"},
|
||||
{"Too short", "12", ""},
|
||||
{"Empty", "", ""},
|
||||
{"Non-numeric", "abcde", ""},
|
||||
{"Unrecognized prefix", "00100", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ZipToState(tt.zip)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateToClimateRegion_AllStatesPresent(t *testing.T) {
|
||||
// All 50 states + DC should be in the map
|
||||
allStates := []string{
|
||||
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA",
|
||||
"HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD",
|
||||
"MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
|
||||
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC",
|
||||
"SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
|
||||
"DC",
|
||||
}
|
||||
|
||||
for _, state := range allStates {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
regionID := GetClimateRegionIDByState(state)
|
||||
assert.Greater(t, regionID, uint(0), "State %s should map to a region", state)
|
||||
assert.LessOrEqual(t, regionID, uint(8), "State %s region should be 1-8, got %d", state, regionID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,26 @@ func (s *TaskTemplateService) GetByID(id uint) (*responses.TaskTemplateResponse,
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetByRegion returns templates for a specific climate region.
|
||||
// Accepts either a state abbreviation or ZIP code (state takes priority).
|
||||
// ZIP codes are resolved to a state via the ZipToState lookup.
|
||||
func (s *TaskTemplateService) GetByRegion(state, zip string) ([]responses.TaskTemplateResponse, error) {
|
||||
// Resolve ZIP to state if no state provided
|
||||
if state == "" && zip != "" {
|
||||
state = ZipToState(zip)
|
||||
}
|
||||
|
||||
regionID := GetClimateRegionIDByState(state)
|
||||
if regionID == 0 {
|
||||
return []responses.TaskTemplateResponse{}, nil
|
||||
}
|
||||
templates, err := s.templateRepo.GetByRegion(regionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responses.NewTaskTemplateListResponse(templates), nil
|
||||
}
|
||||
|
||||
// Count returns the total count of active templates
|
||||
func (s *TaskTemplateService) Count() (int64, error) {
|
||||
return s.templateRepo.Count()
|
||||
|
||||
Reference in New Issue
Block a user