Files
honeyDueAPI/internal/dto/responses/task.go
Trey t 42a5533a56 Fix 113 hardening issues across entire Go backend
Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:13 -05:00

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