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:
Trey t
2026-03-05 15:15:30 -06:00
parent 72db9050f8
commit 793e50ce52
14 changed files with 873 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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