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"` 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"` } // 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, 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"` }