Files
honeyDueAPI/internal/dto/responses/task.go
Trey t 237c6b84ee
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
Onboarding: template backlink, bulk-create endpoint, climate-region scoring
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>
2026-04-14 15:23:57 -05:00

416 lines
15 KiB
Go

package responses
import (
"fmt"
"time"
"github.com/shopspring/decimal"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/task/categorization"
"github.com/treytartt/honeydue-api/internal/task/predicates"
)
// TaskCategoryResponse represents a task category
type TaskCategoryResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
}
// TaskPriorityResponse represents a task priority
type TaskPriorityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
}
// TaskFrequencyResponse represents a task frequency
type TaskFrequencyResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Days *int `json:"days"`
DisplayOrder int `json:"display_order"`
}
// TaskUserResponse represents a user in task context
type TaskUserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// TaskCompletionImageResponse represents a completion image
type TaskCompletionImageResponse struct {
ID uint `json:"id"`
ImageURL string `json:"image_url"`
MediaURL string `json:"media_url"` // Authenticated endpoint: /api/media/completion-image/{id}
Caption string `json:"caption"`
}
// TaskCompletionResponse represents a task completion
type TaskCompletionResponse struct {
ID uint `json:"id"`
TaskID uint `json:"task_id"`
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
CompletedAt time.Time `json:"completed_at"`
Notes string `json:"notes"`
ActualCost *decimal.Decimal `json:"actual_cost"`
Rating *int `json:"rating"`
Images []TaskCompletionImageResponse `json:"images"`
CreatedAt time.Time `json:"created_at"`
Task *TaskResponse `json:"task,omitempty"` // Updated task after completion
}
// TaskResponse represents a task in the API response
type TaskResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
CreatedByID uint `json:"created_by_id"`
CreatedBy *TaskUserResponse `json:"created_by,omitempty"`
AssignedToID *uint `json:"assigned_to_id"`
AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category *TaskCategoryResponse `json:"category,omitempty"`
PriorityID *uint `json:"priority_id"`
Priority *TaskPriorityResponse `json:"priority,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
CustomIntervalDays *int `json:"custom_interval_days"` // For "Custom" frequency, user-specified days
InProgress bool `json:"in_progress"`
DueDate *time.Time `json:"due_date"`
NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"`
ContractorID *uint `json:"contractor_id"`
IsCancelled bool `json:"is_cancelled"`
IsArchived bool `json:"is_archived"`
ParentTaskID *uint `json:"parent_task_id"`
TemplateID *uint `json:"template_id,omitempty"` // Backlink to the TaskTemplate this task was created from
CompletionCount int `json:"completion_count"`
KanbanColumn string `json:"kanban_column,omitempty"` // Which kanban column this task belongs to
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BulkCreateTasksResponse is returned by POST /api/tasks/bulk/.
// All entries are created in a single transaction — if any insert fails the
// whole batch is rolled back and no partial state is visible.
type BulkCreateTasksResponse struct {
Tasks []TaskResponse `json:"tasks"`
Summary TotalSummary `json:"summary"`
CreatedCount int `json:"created_count"`
}
// Note: Pagination removed - list endpoints now return arrays directly
// KanbanColumnResponse represents a kanban column
type KanbanColumnResponse struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
ButtonTypes []string `json:"button_types"`
Icons map[string]string `json:"icons"`
Color string `json:"color"`
Tasks []TaskResponse `json:"tasks"`
Count int `json:"count"`
}
// KanbanBoardResponse represents the kanban board
// NOTE: Summary statistics are calculated client-side from kanban data
type KanbanBoardResponse struct {
Columns []KanbanColumnResponse `json:"columns"`
DaysThreshold int `json:"days_threshold"`
ResidenceID string `json:"residence_id"`
}
// Note: TaskCompletionListResponse pagination removed - returns arrays directly
// === Factory Functions ===
// NewTaskCategoryResponse creates a TaskCategoryResponse from a model
func NewTaskCategoryResponse(c *models.TaskCategory) *TaskCategoryResponse {
if c == nil {
return nil
}
return &TaskCategoryResponse{
ID: c.ID,
Name: c.Name,
Description: c.Description,
Icon: c.Icon,
Color: c.Color,
DisplayOrder: c.DisplayOrder,
}
}
// NewTaskPriorityResponse creates a TaskPriorityResponse from a model
func NewTaskPriorityResponse(p *models.TaskPriority) *TaskPriorityResponse {
if p == nil {
return nil
}
return &TaskPriorityResponse{
ID: p.ID,
Name: p.Name,
Level: p.Level,
Color: p.Color,
DisplayOrder: p.DisplayOrder,
}
}
// NewTaskFrequencyResponse creates a TaskFrequencyResponse from a model
func NewTaskFrequencyResponse(f *models.TaskFrequency) *TaskFrequencyResponse {
if f == nil {
return nil
}
return &TaskFrequencyResponse{
ID: f.ID,
Name: f.Name,
Days: f.Days,
DisplayOrder: f.DisplayOrder,
}
}
// NewTaskUserResponse creates a TaskUserResponse from a User model
func NewTaskUserResponse(u *models.User) *TaskUserResponse {
if u == nil {
return nil
}
return &TaskUserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
FirstName: u.FirstName,
LastName: u.LastName,
}
}
// NewTaskCompletionResponse creates a TaskCompletionResponse from a model
func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse {
resp := TaskCompletionResponse{
ID: c.ID,
TaskID: c.TaskID,
CompletedAt: c.CompletedAt,
Notes: c.Notes,
ActualCost: c.ActualCost,
Rating: c.Rating,
Images: make([]TaskCompletionImageResponse, 0),
CreatedAt: c.CreatedAt,
}
if c.CompletedBy.ID != 0 {
resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy)
}
// Convert images with authenticated media URLs
for _, img := range c.Images {
resp.Images = append(resp.Images, TaskCompletionImageResponse{
ID: img.ID,
ImageURL: img.ImageURL,
MediaURL: fmt.Sprintf("/api/media/completion-image/%d", img.ID), // Authenticated endpoint
Caption: img.Caption,
})
}
return resp
}
// NewTaskResponse creates a TaskResponse from a Task model
// Always includes kanban_column using default 30-day threshold
func NewTaskResponse(t *models.Task) TaskResponse {
return NewTaskResponseWithThreshold(t, 30)
}
// NewTaskResponseWithThreshold creates a TaskResponse with a custom days threshold for kanban column.
// WARNING: Uses UTC time for kanban column. Prefer NewTaskResponseWithTime for timezone-aware responses.
func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskResponse {
return newTaskResponseInternal(t, daysThreshold, time.Now().UTC())
}
// NewTaskResponseWithTime creates a TaskResponse with timezone-aware kanban column categorization.
// The `now` parameter should be the start of day in the user's timezone.
func NewTaskResponseWithTime(t *models.Task, daysThreshold int, now time.Time) TaskResponse {
return newTaskResponseInternal(t, daysThreshold, now)
}
// newTaskResponseInternal is the internal implementation for creating task responses
func newTaskResponseInternal(t *models.Task, daysThreshold int, now time.Time) TaskResponse {
resp := TaskResponse{
ID: t.ID,
ResidenceID: t.ResidenceID,
CreatedByID: t.CreatedByID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
PriorityID: t.PriorityID,
FrequencyID: t.FrequencyID,
CustomIntervalDays: t.CustomIntervalDays,
InProgress: t.InProgress,
AssignedToID: t.AssignedToID,
DueDate: t.DueDate,
NextDueDate: t.NextDueDate,
EstimatedCost: t.EstimatedCost,
ActualCost: t.ActualCost,
ContractorID: t.ContractorID,
IsCancelled: t.IsCancelled,
IsArchived: t.IsArchived,
ParentTaskID: t.ParentTaskID,
TemplateID: t.TaskTemplateID,
CompletionCount: predicates.GetCompletionCount(t),
KanbanColumn: DetermineKanbanColumnWithTime(t, daysThreshold, now),
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
if t.CreatedBy.ID != 0 {
resp.CreatedBy = NewTaskUserResponse(&t.CreatedBy)
}
if t.AssignedTo != nil {
resp.AssignedTo = NewTaskUserResponse(t.AssignedTo)
}
if t.Category != nil {
resp.Category = NewTaskCategoryResponse(t.Category)
}
if t.Priority != nil {
resp.Priority = NewTaskPriorityResponse(t.Priority)
}
if t.Frequency != nil {
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
}
return resp
}
// NewTaskListResponse creates a list of task responses
func NewTaskListResponse(tasks []models.Task) []TaskResponse {
results := make([]TaskResponse, len(tasks))
for i, t := range tasks {
results[i] = NewTaskResponse(&t)
}
return results
}
// NewKanbanBoardResponse creates a KanbanBoardResponse from a KanbanBoard model.
// The `now` parameter should be the start of day in the user's timezone so that
// individual task kanban columns are categorized consistently with the board query.
func NewKanbanBoardResponse(board *models.KanbanBoard, residenceID uint, now time.Time) KanbanBoardResponse {
columns := make([]KanbanColumnResponse, len(board.Columns))
for i, col := range board.Columns {
tasks := make([]TaskResponse, len(col.Tasks))
for j, t := range col.Tasks {
tasks[j] = NewTaskResponseWithTime(&t, board.DaysThreshold, now)
}
columns[i] = KanbanColumnResponse{
Name: col.Name,
DisplayName: col.DisplayName,
ButtonTypes: col.ButtonTypes,
Icons: col.Icons,
Color: col.Color,
Tasks: tasks,
Count: col.Count,
}
}
return KanbanBoardResponse{
Columns: columns,
DaysThreshold: board.DaysThreshold,
ResidenceID: fmt.Sprintf("%d", residenceID),
}
}
// NewKanbanBoardResponseForAll creates a KanbanBoardResponse for all residences (no specific residence ID).
// The `now` parameter should be the start of day in the user's timezone so that
// individual task kanban columns are categorized consistently with the board query.
func NewKanbanBoardResponseForAll(board *models.KanbanBoard, now time.Time) KanbanBoardResponse {
columns := make([]KanbanColumnResponse, len(board.Columns))
for i, col := range board.Columns {
tasks := make([]TaskResponse, len(col.Tasks))
for j, t := range col.Tasks {
tasks[j] = NewTaskResponseWithTime(&t, board.DaysThreshold, now)
}
columns[i] = KanbanColumnResponse{
Name: col.Name,
DisplayName: col.DisplayName,
ButtonTypes: col.ButtonTypes,
Icons: col.Icons,
Color: col.Color,
Tasks: tasks,
Count: col.Count,
}
}
return KanbanBoardResponse{
Columns: columns,
DaysThreshold: board.DaysThreshold,
ResidenceID: "all",
}
}
// NewTaskCompletionListResponse creates a list of task completion responses
func NewTaskCompletionListResponse(completions []models.TaskCompletion) []TaskCompletionResponse {
results := make([]TaskCompletionResponse, len(completions))
for i, c := range completions {
results[i] = NewTaskCompletionResponse(&c)
}
return results
}
// NewTaskCompletionWithTaskResponse creates a TaskCompletionResponse with the updated task included.
// WARNING: Uses UTC time for kanban column. Prefer NewTaskCompletionWithTaskResponseWithTime for timezone-aware responses.
func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Task, daysThreshold int) TaskCompletionResponse {
return NewTaskCompletionWithTaskResponseWithTime(c, task, daysThreshold, time.Now().UTC())
}
// NewTaskCompletionWithTaskResponseWithTime creates a TaskCompletionResponse with timezone-aware kanban categorization.
// The `now` parameter should be the start of day in the user's timezone.
func NewTaskCompletionWithTaskResponseWithTime(c *models.TaskCompletion, task *models.Task, daysThreshold int, now time.Time) TaskCompletionResponse {
resp := NewTaskCompletionResponse(c)
if task != nil {
taskResp := NewTaskResponseWithTime(task, daysThreshold, now)
resp.Task = &taskResp
}
return resp
}
// DetermineKanbanColumn determines which kanban column a task belongs to.
// Delegates to internal/task/categorization package which is the single source
// of truth for task categorization logic.
//
// WARNING: This uses UTC time which may cause incorrect categorization when
// server time is past midnight UTC but user's local time is still the previous day.
// Prefer DetermineKanbanColumnWithTime for timezone-aware categorization.
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
return categorization.DetermineKanbanColumn(task, daysThreshold)
}
// DetermineKanbanColumnWithTime determines which kanban column a task belongs to
// using a specific time (should be start of day in user's timezone).
func DetermineKanbanColumnWithTime(task *models.Task, daysThreshold int, now time.Time) string {
return categorization.DetermineKanbanColumnWithTime(task, daysThreshold, now)
}
// === Response Wrappers with Summary ===
// These wrap CRUD responses with TotalSummary to eliminate extra API calls
// TaskWithSummaryResponse wraps TaskResponse with TotalSummary
type TaskWithSummaryResponse struct {
Data TaskResponse `json:"data"`
Summary TotalSummary `json:"summary"`
}
// TaskCompletionWithSummaryResponse wraps TaskCompletionResponse with TotalSummary
type TaskCompletionWithSummaryResponse struct {
Data TaskCompletionResponse `json:"data"`
Summary TotalSummary `json:"summary"`
}
// DeleteWithSummaryResponse for delete operations
type DeleteWithSummaryResponse struct {
Data string `json:"data"`
Summary TotalSummary `json:"summary"`
}