Field existed in Task model but was missing from API response. Aligns Go API contract with KMM mobile model.
405 lines
15 KiB
Go
405 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"`
|
|
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"`
|
|
}
|