Onboarding: template backlink, bulk-create endpoint, climate-region scoring
Some checks failed
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled

Clients that send users through a multi-task onboarding step no longer
loop N POST /api/tasks/ calls and no longer create "orphan" tasks with
no reference to the TaskTemplate they came from.

Task model
- New task_template_id column + GORM FK (migration 000016)
- CreateTaskRequest.template_id, TaskResponse.template_id
- task_service.CreateTask persists the backlink

Bulk endpoint
- POST /api/tasks/bulk/ — 1-50 tasks in a single transaction,
  returns every created row + TotalSummary. Single residence access
  check, per-entry residence_id is overridden with batch value
- task_handler.BulkCreateTasks + task_service.BulkCreateTasks using
  db.Transaction; task_repo.CreateTx + FindByIDTx helpers

Climate-region scoring
- templateConditions gains ClimateRegionID; suggestion_service scores
  residence.PostalCode -> ZipToState -> GetClimateRegionIDByState against
  the template's conditions JSON (no penalty on mismatch / unknown ZIP)
- regionMatchBonus 0.35, totalProfileFields 14 -> 15
- Standalone GET /api/tasks/templates/by-region/ removed; legacy
  task_tasktemplate_regions many-to-many dropped (migration 000017).
  Region affinity now lives entirely in the template's conditions JSON

Tests
- +11 cases across task_service_test, task_handler_test, suggestion_
  service_test: template_id persistence, bulk rollback + cap + auth,
  region match / mismatch / no-ZIP / unknown-ZIP / stacks-with-others

Docs
- docs/openapi.yaml: /tasks/bulk/ + BulkCreateTasks schemas, template_id
  on TaskResponse + CreateTaskRequest, /templates/by-region/ removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-14 15:23:57 -05:00
parent 33eee812b6
commit 237c6b84ee
24 changed files with 678 additions and 169 deletions

View File

@@ -189,6 +189,7 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint, n
NextDueDate: dueDate, // Initialize next_due_date to due_date
EstimatedCost: req.EstimatedCost,
ContractorID: req.ContractorID,
TaskTemplateID: req.TemplateID,
}
if err := s.taskRepo.Create(task); err != nil {
@@ -207,6 +208,83 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint, n
}, nil
}
// BulkCreateTasks inserts all tasks in a single transaction. If any task
// fails validation or insert, the entire batch is rolled back. The top-level
// ResidenceID overrides whatever was set on individual entries so that a
// single access check covers the whole batch.
//
// `now` should be the start of day in the user's timezone for accurate
// kanban column categorisation on the returned task list.
func (s *TaskService) BulkCreateTasks(req *requests.BulkCreateTasksRequest, userID uint, now time.Time) (*responses.BulkCreateTasksResponse, error) {
if len(req.Tasks) == 0 {
return nil, apperrors.BadRequest("error.task_list_empty")
}
// Check residence access once.
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
createdIDs := make([]uint, 0, len(req.Tasks))
err = s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
for i := range req.Tasks {
entry := req.Tasks[i]
// Force the residence ID to the batch-level value so clients
// can't straddle residences in one call.
entry.ResidenceID = req.ResidenceID
dueDate := entry.DueDate.ToTimePtr()
task := &models.Task{
ResidenceID: req.ResidenceID,
CreatedByID: userID,
Title: entry.Title,
Description: entry.Description,
CategoryID: entry.CategoryID,
PriorityID: entry.PriorityID,
FrequencyID: entry.FrequencyID,
CustomIntervalDays: entry.CustomIntervalDays,
InProgress: entry.InProgress,
AssignedToID: entry.AssignedToID,
DueDate: dueDate,
NextDueDate: dueDate,
EstimatedCost: entry.EstimatedCost,
ContractorID: entry.ContractorID,
TaskTemplateID: entry.TemplateID,
}
if err := s.taskRepo.CreateTx(tx, task); err != nil {
return fmt.Errorf("create task %d of %d: %w", i+1, len(req.Tasks), err)
}
createdIDs = append(createdIDs, task.ID)
}
return nil
})
if err != nil {
return nil, apperrors.Internal(err)
}
// Reload the just-created tasks with preloads for the response. Reads
// happen outside the transaction — rows are already committed.
created := make([]responses.TaskResponse, 0, len(createdIDs))
for _, id := range createdIDs {
t, ferr := s.taskRepo.FindByID(id)
if ferr != nil {
return nil, apperrors.Internal(ferr)
}
created = append(created, responses.NewTaskResponseWithTime(t, 30, now))
}
return &responses.BulkCreateTasksResponse{
Tasks: created,
Summary: s.getSummaryForUser(userID),
CreatedCount: len(created),
}, nil
}
// UpdateTask updates a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) {