diff --git a/admin/src/app/(dashboard)/lookups/page.tsx b/admin/src/app/(dashboard)/lookups/page.tsx index ce45253..7863d3e 100644 --- a/admin/src/app/(dashboard)/lookups/page.tsx +++ b/admin/src/app/(dashboard)/lookups/page.tsx @@ -38,7 +38,6 @@ import { const lookupTabs = [ { key: 'categories', label: 'Task Categories', api: lookupsApi.categories }, { key: 'priorities', label: 'Task Priorities', api: lookupsApi.priorities }, - { key: 'statuses', label: 'Task Statuses', api: lookupsApi.statuses }, { key: 'frequencies', label: 'Task Frequencies', api: lookupsApi.frequencies }, { key: 'residenceTypes', label: 'Residence Types', api: lookupsApi.residenceTypes }, { key: 'specialties', label: 'Contractor Specialties', api: lookupsApi.specialties }, @@ -320,12 +319,12 @@ export default function LookupsPage() { Reference Data - Configure task categories, priorities, statuses, frequencies, residence types, and contractor specialties + Configure task categories, priorities, frequencies, residence types, and contractor specialties - + {lookupTabs.map((tab) => ( {tab.label} diff --git a/admin/src/app/(dashboard)/residences/[id]/client.tsx b/admin/src/app/(dashboard)/residences/[id]/client.tsx index efff038..8920485 100644 --- a/admin/src/app/(dashboard)/residences/[id]/client.tsx +++ b/admin/src/app/(dashboard)/residences/[id]/client.tsx @@ -248,7 +248,7 @@ export function ResidenceDetailClient() { Title - Status + In Progress Priority Category Due Date @@ -271,7 +271,7 @@ export function ResidenceDetailClient() { - {task.status_name || '-'} + {task.in_progress ? '✓' : '—'} {task.priority_name || '-'} {task.category_name || '-'} diff --git a/admin/src/app/(dashboard)/settings/page.tsx b/admin/src/app/(dashboard)/settings/page.tsx index dfaba6a..496fd03 100644 --- a/admin/src/app/(dashboard)/settings/page.tsx +++ b/admin/src/app/(dashboard)/settings/page.tsx @@ -108,7 +108,7 @@ export default function SettingsPage() { This will insert or update all lookup tables including:
  • Residence types
  • -
  • Task categories, priorities, statuses, frequencies
  • +
  • Task categories, priorities, frequencies
  • Contractor specialties
  • Subscription tiers and feature benefits
  • Task templates (60+ predefined tasks)
  • diff --git a/admin/src/app/(dashboard)/tasks/[id]/client.tsx b/admin/src/app/(dashboard)/tasks/[id]/client.tsx index 9ef0a15..d2ec15c 100644 --- a/admin/src/app/(dashboard)/tasks/[id]/client.tsx +++ b/admin/src/app/(dashboard)/tasks/[id]/client.tsx @@ -117,8 +117,8 @@ export function TaskDetailClient() {
    {task.priority_name || '-'}
    -
    Status
    -
    {task.status_name || '-'}
    +
    In Progress
    +
    {task.in_progress ? 'Yes' : 'No'}
    Due Date
    diff --git a/admin/src/app/(dashboard)/tasks/[id]/edit/page.tsx b/admin/src/app/(dashboard)/tasks/[id]/edit/page.tsx index bac8cf8..bfc9c53 100644 --- a/admin/src/app/(dashboard)/tasks/[id]/edit/page.tsx +++ b/admin/src/app/(dashboard)/tasks/[id]/edit/page.tsx @@ -62,11 +62,6 @@ export default function EditTaskPage() { queryFn: () => lookupsApi.priorities.list(), }); - const { data: statuses, isLoading: statusesLoading } = useQuery({ - queryKey: ['lookups', 'statuses'], - queryFn: () => lookupsApi.statuses.list(), - }); - const { data: frequencies, isLoading: frequenciesLoading } = useQuery({ queryKey: ['lookups', 'frequencies'], queryFn: () => lookupsApi.frequencies.list(), @@ -85,7 +80,7 @@ export default function EditTaskPage() { const [formInitialized, setFormInitialized] = useState(false); // Wait for ALL data including lookups before initializing form - const lookupsLoaded = !categoriesLoading && !prioritiesLoading && !statusesLoading && !frequenciesLoading; + const lookupsLoaded = !categoriesLoading && !prioritiesLoading && !frequenciesLoading; useEffect(() => { if (task && lookupsLoaded && !formInitialized) { @@ -97,7 +92,7 @@ export default function EditTaskPage() { description: task.description, category_id: task.category_id, priority_id: task.priority_id, - status_id: task.status_id, + in_progress: task.in_progress, frequency_id: task.frequency_id, due_date: task.due_date, next_due_date: task.next_due_date, @@ -323,30 +318,15 @@ export default function EditTaskPage() {
    -
    - - +
    + updateField('in_progress', e.target.checked)} + className="h-4 w-4 rounded border-gray-300" + /> +
    diff --git a/admin/src/app/(dashboard)/tasks/page.tsx b/admin/src/app/(dashboard)/tasks/page.tsx index 73275fb..3d93010 100644 --- a/admin/src/app/(dashboard)/tasks/page.tsx +++ b/admin/src/app/(dashboard)/tasks/page.tsx @@ -58,9 +58,9 @@ const columns: ColumnDef[] = [ cell: ({ row }) => row.original.priority_name || '-', }, { - accessorKey: 'status_name', - header: 'Status', - cell: ({ row }) => row.original.status_name || '-', + accessorKey: 'in_progress', + header: 'In Progress', + cell: ({ row }) => row.original.in_progress ? '✓' : '—', }, { accessorKey: 'due_date', diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 1235c14..35076c5 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -517,7 +517,6 @@ const createLookupApi = (endpoint: string) => ({ export const lookupsApi = { categories: createLookupApi('categories'), priorities: createLookupApi('priorities'), - statuses: createLookupApi('statuses'), frequencies: createLookupApi('frequencies'), residenceTypes: createLookupApi('residence-types'), specialties: createLookupApi('specialties'), diff --git a/admin/src/types/models.ts b/admin/src/types/models.ts index b8d8031..7416614 100644 --- a/admin/src/types/models.ts +++ b/admin/src/types/models.ts @@ -186,8 +186,7 @@ export interface Task { category_name?: string; priority_id?: number; priority_name?: string; - status_id?: number; - status_name?: string; + in_progress: boolean; frequency_id?: number; frequency_name?: string; due_date?: string; @@ -211,7 +210,7 @@ export interface TaskListParams extends ListParams { residence_id?: number; category_id?: number; priority_id?: number; - status_id?: number; + in_progress?: boolean; is_cancelled?: boolean; is_archived?: boolean; } @@ -223,7 +222,7 @@ export interface CreateTaskRequest { description?: string; category_id?: number; priority_id?: number; - status_id?: number; + in_progress?: boolean; frequency_id?: number; assigned_to_id?: number; due_date?: string; @@ -239,7 +238,7 @@ export interface UpdateTaskRequest { description?: string; category_id?: number; priority_id?: number; - status_id?: number; + in_progress?: boolean; frequency_id?: number; due_date?: string; next_due_date?: string; diff --git a/internal/admin/dto/requests.go b/internal/admin/dto/requests.go index 05afa14..39b2aab 100644 --- a/internal/admin/dto/requests.go +++ b/internal/admin/dto/requests.go @@ -118,7 +118,7 @@ type TaskFilters struct { ResidenceID *uint `form:"residence_id"` CategoryID *uint `form:"category_id"` PriorityID *uint `form:"priority_id"` - StatusID *uint `form:"status_id"` + InProgress *bool `form:"in_progress"` IsCancelled *bool `form:"is_cancelled"` IsArchived *bool `form:"is_archived"` } @@ -132,8 +132,8 @@ type UpdateTaskRequest struct { Description *string `json:"description"` CategoryID *uint `json:"category_id"` PriorityID *uint `json:"priority_id"` - StatusID *uint `json:"status_id"` FrequencyID *uint `json:"frequency_id"` + InProgress *bool `json:"in_progress"` DueDate *string `json:"due_date"` NextDueDate *string `json:"next_due_date"` EstimatedCost *float64 `json:"estimated_cost"` @@ -265,18 +265,18 @@ type CreateResidenceRequest struct { // CreateTaskRequest for creating a new task type CreateTaskRequest struct { - ResidenceID uint `json:"residence_id" binding:"required"` - CreatedByID uint `json:"created_by_id" binding:"required"` - Title string `json:"title" binding:"required,max=200"` - Description string `json:"description"` - CategoryID *uint `json:"category_id"` - PriorityID *uint `json:"priority_id"` - StatusID *uint `json:"status_id"` - FrequencyID *uint `json:"frequency_id"` - AssignedToID *uint `json:"assigned_to_id"` - DueDate *string `json:"due_date"` + ResidenceID uint `json:"residence_id" binding:"required"` + CreatedByID uint `json:"created_by_id" binding:"required"` + Title string `json:"title" binding:"required,max=200"` + Description string `json:"description"` + CategoryID *uint `json:"category_id"` + PriorityID *uint `json:"priority_id"` + FrequencyID *uint `json:"frequency_id"` + InProgress bool `json:"in_progress"` + AssignedToID *uint `json:"assigned_to_id"` + DueDate *string `json:"due_date"` EstimatedCost *float64 `json:"estimated_cost"` - ContractorID *uint `json:"contractor_id"` + ContractorID *uint `json:"contractor_id"` } // CreateContractorRequest for creating a new contractor diff --git a/internal/admin/dto/responses.go b/internal/admin/dto/responses.go index 76eab24..c73475f 100644 --- a/internal/admin/dto/responses.go +++ b/internal/admin/dto/responses.go @@ -126,10 +126,9 @@ type TaskResponse struct { CategoryName *string `json:"category_name,omitempty"` PriorityID *uint `json:"priority_id,omitempty"` PriorityName *string `json:"priority_name,omitempty"` - StatusID *uint `json:"status_id,omitempty"` - StatusName *string `json:"status_name,omitempty"` FrequencyID *uint `json:"frequency_id,omitempty"` FrequencyName *string `json:"frequency_name,omitempty"` + InProgress bool `json:"in_progress"` DueDate *string `json:"due_date,omitempty"` NextDueDate *string `json:"next_due_date,omitempty"` EstimatedCost *float64 `json:"estimated_cost,omitempty"` diff --git a/internal/admin/handlers/dashboard_handler.go b/internal/admin/handlers/dashboard_handler.go index c1c00b2..4319c40 100644 --- a/internal/admin/handlers/dashboard_handler.go +++ b/internal/admin/handlers/dashboard_handler.go @@ -62,13 +62,6 @@ type TaskStats struct { OnHold int64 `json:"on_hold"` } -// TaskStatusCount holds a single status count -type TaskStatusCount struct { - StatusID uint `json:"status_id"` - StatusName string `json:"status_name"` - Count int64 `json:"count"` -} - // ContractorStats holds contractor-related statistics type ContractorStats struct { Total int64 `json:"total"` @@ -123,14 +116,13 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) { h.db.Model(&models.Task{}).Scopes(scopes.ScopeCancelled).Count(&stats.Tasks.Cancelled) h.db.Model(&models.Task{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Tasks.New30d) - // Task counts by status (using LEFT JOIN to handle tasks with no status) - // Note: These status counts use DB status names, not kanban categorization + // Task counts by in_progress flag + // Pending: active tasks that are not in progress and not completed h.db.Model(&models.Task{}). - Scopes(scopes.ScopeActive). - Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). - Where("LOWER(task_taskstatus.name) = ? OR task_taskstatus.id IS NULL", "pending"). + Scopes(scopes.ScopeActive, scopes.ScopeNotInProgress, scopes.ScopeNotCompleted). Count(&stats.Tasks.Pending) + // In Progress: active tasks with in_progress = true h.db.Model(&models.Task{}). Scopes(scopes.ScopeActive, scopes.ScopeInProgress). Count(&stats.Tasks.InProgress) @@ -141,11 +133,8 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) { Scopes(scopes.ScopeActive, scopes.ScopeCompleted). Count(&stats.Tasks.Completed) - h.db.Model(&models.Task{}). - Scopes(scopes.ScopeActive). - Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). - Where("LOWER(task_taskstatus.name) = ?", "on hold"). - Count(&stats.Tasks.OnHold) + // OnHold: no longer used with in_progress boolean, set to 0 + stats.Tasks.OnHold = 0 // Overdue: uses consistent logic from internal/task/scopes.ScopeOverdue // Effective date (COALESCE(next_due_date, due_date)) < now, active, not completed diff --git a/internal/admin/handlers/lookup_handler.go b/internal/admin/handlers/lookup_handler.go index bf5d60b..eec1243 100644 --- a/internal/admin/handlers/lookup_handler.go +++ b/internal/admin/handlers/lookup_handler.go @@ -70,29 +70,6 @@ func (h *AdminLookupHandler) refreshPrioritiesCache(ctx context.Context) { h.invalidateSeededDataCache(ctx) } -// refreshStatusesCache invalidates and refreshes the statuses cache -func (h *AdminLookupHandler) refreshStatusesCache(ctx context.Context) { - cache := services.GetCache() - if cache == nil { - return - } - - var statuses []models.TaskStatus - if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil { - log.Warn().Err(err).Msg("Failed to fetch statuses for cache refresh") - return - } - - if err := cache.CacheStatuses(ctx, statuses); err != nil { - log.Warn().Err(err).Msg("Failed to cache statuses") - return - } - log.Debug().Int("count", len(statuses)).Msg("Refreshed statuses cache") - - // Invalidate unified seeded data cache - h.invalidateSeededDataCache(ctx) -} - // refreshFrequenciesCache invalidates and refreshes the frequencies cache func (h *AdminLookupHandler) refreshFrequenciesCache(ctx context.Context) { cache := services.GetCache() @@ -471,149 +448,6 @@ func (h *AdminLookupHandler) DeletePriority(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"}) } -// ========== Task Statuses ========== - -type TaskStatusResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Color string `json:"color"` - DisplayOrder int `json:"display_order"` -} - -type CreateUpdateStatusRequest struct { - Name string `json:"name" binding:"required,max=20"` - Description string `json:"description"` - Color string `json:"color" binding:"max=7"` - DisplayOrder *int `json:"display_order"` -} - -func (h *AdminLookupHandler) ListStatuses(c *gin.Context) { - var statuses []models.TaskStatus - if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statuses"}) - return - } - - responses := make([]TaskStatusResponse, len(statuses)) - for i, s := range statuses { - responses[i] = TaskStatusResponse{ - ID: s.ID, - Name: s.Name, - Description: s.Description, - Color: s.Color, - DisplayOrder: s.DisplayOrder, - } - } - - c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)}) -} - -func (h *AdminLookupHandler) CreateStatus(c *gin.Context) { - var req CreateUpdateStatusRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - status := models.TaskStatus{ - Name: req.Name, - Description: req.Description, - Color: req.Color, - } - if req.DisplayOrder != nil { - status.DisplayOrder = *req.DisplayOrder - } - - if err := h.db.Create(&status).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create status"}) - return - } - - // Refresh cache after creating - h.refreshStatusesCache(c.Request.Context()) - - c.JSON(http.StatusCreated, TaskStatusResponse{ - ID: status.ID, - Name: status.Name, - Description: status.Description, - Color: status.Color, - DisplayOrder: status.DisplayOrder, - }) -} - -func (h *AdminLookupHandler) UpdateStatus(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status ID"}) - return - } - - var status models.TaskStatus - if err := h.db.First(&status, id).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Status not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch status"}) - return - } - - var req CreateUpdateStatusRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - status.Name = req.Name - status.Description = req.Description - status.Color = req.Color - if req.DisplayOrder != nil { - status.DisplayOrder = *req.DisplayOrder - } - - if err := h.db.Save(&status).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"}) - return - } - - // Refresh cache after updating - h.refreshStatusesCache(c.Request.Context()) - - c.JSON(http.StatusOK, TaskStatusResponse{ - ID: status.ID, - Name: status.Name, - Description: status.Description, - Color: status.Color, - DisplayOrder: status.DisplayOrder, - }) -} - -func (h *AdminLookupHandler) DeleteStatus(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status ID"}) - return - } - - var count int64 - h.db.Model(&models.Task{}).Where("status_id = ?", id).Count(&count) - if count > 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete status that is in use by tasks"}) - return - } - - if err := h.db.Delete(&models.TaskStatus{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete status"}) - return - } - - // Refresh cache after deleting - h.refreshStatusesCache(c.Request.Context()) - - c.JSON(http.StatusOK, gin.H{"message": "Status deleted successfully"}) -} - // ========== Task Frequencies ========== type TaskFrequencyResponse struct { diff --git a/internal/admin/handlers/settings_handler.go b/internal/admin/handlers/settings_handler.go index 0446d37..2f3f23e 100644 --- a/internal/admin/handlers/settings_handler.go +++ b/internal/admin/handlers/settings_handler.go @@ -143,16 +143,6 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error } log.Debug().Int("count", len(priorities)).Msg("Cached task priorities") - // Fetch and cache task statuses - var statuses []models.TaskStatus - if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil { - return false, fmt.Errorf("failed to fetch statuses: %w", err) - } - if err := cache.CacheStatuses(ctx, statuses); err != nil { - return false, fmt.Errorf("failed to cache statuses: %w", err) - } - log.Debug().Int("count", len(statuses)).Msg("Cached task statuses") - // Fetch and cache task frequencies var frequencies []models.TaskFrequency if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil { @@ -203,7 +193,6 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error "task_categories": categories, "task_priorities": priorities, "task_frequencies": frequencies, - "task_statuses": statuses, "contractor_specialties": specialties, "task_templates": buildGroupedTemplates(taskTemplates), } diff --git a/internal/admin/handlers/task_handler.go b/internal/admin/handlers/task_handler.go index b1dd713..b69c220 100644 --- a/internal/admin/handlers/task_handler.go +++ b/internal/admin/handlers/task_handler.go @@ -38,8 +38,7 @@ func (h *AdminTaskHandler) List(c *gin.Context) { Preload("Residence"). Preload("CreatedBy"). Preload("Category"). - Preload("Priority"). - Preload("Status") + Preload("Priority") // Apply search if filters.Search != "" { @@ -57,8 +56,8 @@ func (h *AdminTaskHandler) List(c *gin.Context) { if filters.PriorityID != nil { query = query.Where("priority_id = ?", *filters.PriorityID) } - if filters.StatusID != nil { - query = query.Where("status_id = ?", *filters.StatusID) + if filters.InProgress != nil { + query = query.Where("in_progress = ?", *filters.InProgress) } if filters.IsCancelled != nil { query = query.Where("is_cancelled = ?", *filters.IsCancelled) @@ -109,7 +108,6 @@ func (h *AdminTaskHandler) Get(c *gin.Context) { Preload("AssignedTo"). Preload("Category"). Preload("Priority"). - Preload("Status"). Preload("Frequency"). Preload("Completions"). First(&task, id).Error; err != nil { @@ -210,8 +208,8 @@ func (h *AdminTaskHandler) Update(c *gin.Context) { if req.PriorityID != nil { updates["priority_id"] = *req.PriorityID } - if req.StatusID != nil { - updates["status_id"] = *req.StatusID + if req.InProgress != nil { + updates["in_progress"] = *req.InProgress } if req.FrequencyID != nil { updates["frequency_id"] = *req.FrequencyID @@ -254,7 +252,7 @@ func (h *AdminTaskHandler) Update(c *gin.Context) { } // Reload with preloads for response - h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, id) + h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").First(&task, id) c.JSON(http.StatusOK, h.toTaskResponse(&task)) } @@ -287,7 +285,7 @@ func (h *AdminTaskHandler) Create(c *gin.Context) { Description: req.Description, CategoryID: req.CategoryID, PriorityID: req.PriorityID, - StatusID: req.StatusID, + InProgress: req.InProgress, FrequencyID: req.FrequencyID, AssignedToID: req.AssignedToID, ContractorID: req.ContractorID, @@ -311,7 +309,7 @@ func (h *AdminTaskHandler) Create(c *gin.Context) { return } - h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, task.ID) + h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").First(&task, task.ID) c.JSON(http.StatusCreated, h.toTaskResponse(&task)) } @@ -336,7 +334,7 @@ func (h *AdminTaskHandler) Delete(c *gin.Context) { // Soft delete - archive and cancel task.IsArchived = true task.IsCancelled = true - if err := h.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Status", "Frequency", "ParentTask", "Completions").Save(&task).Error; err != nil { + if err := h.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").Save(&task).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"}) return } @@ -373,7 +371,7 @@ func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse { Description: task.Description, CategoryID: task.CategoryID, PriorityID: task.PriorityID, - StatusID: task.StatusID, + InProgress: task.InProgress, FrequencyID: task.FrequencyID, ContractorID: task.ContractorID, ParentTaskID: task.ParentTaskID, @@ -401,9 +399,6 @@ func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse { if task.Priority != nil { response.PriorityName = &task.Priority.Name } - if task.Status != nil { - response.StatusName = &task.Status.Name - } if task.Frequency != nil { response.FrequencyName = &task.Frequency.Name } diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 02cebcc..b43956e 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -263,15 +263,6 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe priorities.DELETE("/:id", lookupHandler.DeletePriority) } - // Task Statuses - statuses := protected.Group("/lookups/statuses") - { - statuses.GET("", lookupHandler.ListStatuses) - statuses.POST("", lookupHandler.CreateStatus) - statuses.PUT("/:id", lookupHandler.UpdateStatus) - statuses.DELETE("/:id", lookupHandler.DeleteStatus) - } - // Task Frequencies frequencies := protected.Group("/lookups/frequencies") { diff --git a/internal/database/database.go b/internal/database/database.go index bdad7c0..7d34ebb 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -114,7 +114,6 @@ func Migrate() error { &models.TaskCategory{}, &models.TaskPriority{}, &models.TaskFrequency{}, - &models.TaskStatus{}, &models.ContractorSpecialty{}, &models.TaskTemplate{}, // Task templates reference category and frequency diff --git a/internal/dto/requests/task.go b/internal/dto/requests/task.go index f77cf13..ffeec11 100644 --- a/internal/dto/requests/task.go +++ b/internal/dto/requests/task.go @@ -59,8 +59,8 @@ type CreateTaskRequest struct { Description string `json:"description"` CategoryID *uint `json:"category_id"` PriorityID *uint `json:"priority_id"` - StatusID *uint `json:"status_id"` FrequencyID *uint `json:"frequency_id"` + InProgress bool `json:"in_progress"` AssignedToID *uint `json:"assigned_to_id"` DueDate *FlexibleDate `json:"due_date"` EstimatedCost *decimal.Decimal `json:"estimated_cost"` @@ -73,8 +73,8 @@ type UpdateTaskRequest struct { Description *string `json:"description"` CategoryID *uint `json:"category_id"` PriorityID *uint `json:"priority_id"` - StatusID *uint `json:"status_id"` FrequencyID *uint `json:"frequency_id"` + InProgress *bool `json:"in_progress"` AssignedToID *uint `json:"assigned_to_id"` DueDate *FlexibleDate `json:"due_date"` EstimatedCost *decimal.Decimal `json:"estimated_cost"` diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index 78c6811..7bbedd7 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -29,15 +29,6 @@ type TaskPriorityResponse struct { DisplayOrder int `json:"display_order"` } -// TaskStatusResponse represents a task status -type TaskStatusResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Color string `json:"color"` - DisplayOrder int `json:"display_order"` -} - // TaskFrequencyResponse represents a task frequency type TaskFrequencyResponse struct { ID uint `json:"id"` @@ -91,10 +82,9 @@ type TaskResponse struct { Category *TaskCategoryResponse `json:"category,omitempty"` PriorityID *uint `json:"priority_id"` Priority *TaskPriorityResponse `json:"priority,omitempty"` - StatusID *uint `json:"status_id"` - Status *TaskStatusResponse `json:"status,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"` @@ -163,20 +153,6 @@ func NewTaskPriorityResponse(p *models.TaskPriority) *TaskPriorityResponse { } } -// NewTaskStatusResponse creates a TaskStatusResponse from a model -func NewTaskStatusResponse(s *models.TaskStatus) *TaskStatusResponse { - if s == nil { - return nil - } - return &TaskStatusResponse{ - ID: s.ID, - Name: s.Name, - Description: s.Description, - Color: s.Color, - DisplayOrder: s.DisplayOrder, - } -} - // NewTaskFrequencyResponse creates a TaskFrequencyResponse from a model func NewTaskFrequencyResponse(f *models.TaskFrequency) *TaskFrequencyResponse { if f == nil { @@ -247,8 +223,8 @@ func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskRespons Description: t.Description, CategoryID: t.CategoryID, PriorityID: t.PriorityID, - StatusID: t.StatusID, FrequencyID: t.FrequencyID, + InProgress: t.InProgress, AssignedToID: t.AssignedToID, DueDate: t.DueDate, NextDueDate: t.NextDueDate, @@ -276,9 +252,6 @@ func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskRespons if t.Priority != nil { resp.Priority = NewTaskPriorityResponse(t.Priority) } - if t.Status != nil { - resp.Status = NewTaskStatusResponse(t.Status) - } if t.Frequency != nil { resp.Frequency = NewTaskFrequencyResponse(t.Frequency) } diff --git a/internal/handlers/static_data_handler.go b/internal/handlers/static_data_handler.go index 513133c..93c96e4 100644 --- a/internal/handlers/static_data_handler.go +++ b/internal/handlers/static_data_handler.go @@ -15,13 +15,12 @@ import ( // SeededDataResponse represents the unified seeded data response type SeededDataResponse struct { - ResidenceTypes interface{} `json:"residence_types"` - TaskCategories interface{} `json:"task_categories"` - TaskPriorities interface{} `json:"task_priorities"` - TaskFrequencies interface{} `json:"task_frequencies"` - TaskStatuses interface{} `json:"task_statuses"` - ContractorSpecialties interface{} `json:"contractor_specialties"` - TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"` + ResidenceTypes interface{} `json:"residence_types"` + TaskCategories interface{} `json:"task_categories"` + TaskPriorities interface{} `json:"task_priorities"` + TaskFrequencies interface{} `json:"task_frequencies"` + ContractorSpecialties interface{} `json:"contractor_specialties"` + TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"` } // StaticDataHandler handles static/lookup data endpoints @@ -113,12 +112,6 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) { return } - taskStatuses, err := h.taskService.GetStatuses() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_statuses")}) - return - } - contractorSpecialties, err := h.contractorService.GetSpecialties() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_contractor_specialties")}) @@ -137,7 +130,6 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) { TaskCategories: taskCategories, TaskPriorities: taskPriorities, TaskFrequencies: taskFrequencies, - TaskStatuses: taskStatuses, ContractorSpecialties: contractorSpecialties, TaskTemplates: taskTemplates, } diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index 2bbcd6c..d4efa37 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -517,16 +517,6 @@ func (h *TaskHandler) GetPriorities(c *gin.Context) { c.JSON(http.StatusOK, priorities) } -// GetStatuses handles GET /api/tasks/statuses/ -func (h *TaskHandler) GetStatuses(c *gin.Context) { - statuses, err := h.taskService.GetStatuses() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, statuses) -} - // GetFrequencies handles GET /api/tasks/frequencies/ func (h *TaskHandler) GetFrequencies(c *gin.Context) { frequencies, err := h.taskService.GetFrequencies() diff --git a/internal/handlers/task_handler_test.go b/internal/handlers/task_handler_test.go index b777d3d..91ec303 100644 --- a/internal/handlers/task_handler_test.go +++ b/internal/handlers/task_handler_test.go @@ -610,7 +610,6 @@ func TestTaskHandler_GetLookups(t *testing.T) { authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/categories/", handler.GetCategories) authGroup.GET("/priorities/", handler.GetPriorities) - authGroup.GET("/statuses/", handler.GetStatuses) authGroup.GET("/frequencies/", handler.GetFrequencies) t.Run("get categories", func(t *testing.T) { @@ -642,18 +641,6 @@ func TestTaskHandler_GetLookups(t *testing.T) { assert.Contains(t, response[0], "level") }) - t.Run("get statuses", func(t *testing.T) { - w := testutil.MakeRequest(router, "GET", "/api/tasks/statuses/", nil, "test-token") - - testutil.AssertStatusCode(t, w, http.StatusOK) - - var response []map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Greater(t, len(response), 0) - }) - t.Run("get frequencies", func(t *testing.T) { w := testutil.MakeRequest(router, "GET", "/api/tasks/frequencies/", nil, "test-token") diff --git a/internal/integration/contractor_sharing_test.go b/internal/integration/contractor_sharing_test.go index 7d989d8..c67d241 100644 --- a/internal/integration/contractor_sharing_test.go +++ b/internal/integration/contractor_sharing_test.go @@ -32,7 +32,8 @@ func TestIntegration_ContractorSharingFlow(t *testing.T) { var residenceResp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &residenceResp) require.NoError(t, err) - residenceCID := residenceResp["id"].(float64) + residenceData := residenceResp["data"].(map[string]interface{}) + residenceCID := residenceData["id"].(float64) // ========== User A shares residence C with User B ========== // Generate share code @@ -191,7 +192,8 @@ func TestIntegration_ContractorAccessWithoutResidenceShare(t *testing.T) { var residenceResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &residenceResp) - residenceID := residenceResp["id"].(float64) + residenceData := residenceResp["data"].(map[string]interface{}) + residenceID := residenceData["id"].(float64) // User A creates a contractor tied to the residence (NOT shared with User B) contractorBody := map[string]interface{}{ @@ -235,9 +237,10 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) { w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, userAToken) require.Equal(t, http.StatusCreated, w.Code) - var residenceResp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &residenceResp) - residenceID := residenceResp["id"].(float64) + var residenceResp2 map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &residenceResp2) + residenceData2 := residenceResp2["data"].(map[string]interface{}) + residenceID := residenceData2["id"].(float64) // Share with User B w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/"+formatID(residenceID)+"/generate-share-code", nil, userAToken) @@ -259,9 +262,9 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) { w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBody, userAToken) require.Equal(t, http.StatusCreated, w.Code) - var contractorResp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &contractorResp) - contractorID := contractorResp["id"].(float64) + var contractorResp3 map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &contractorResp3) + contractorID3 := contractorResp3["id"].(float64) // User B (with access) can update the contractor // Note: Must include residence_id to keep it tied to the residence @@ -269,7 +272,7 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) { "name": "Updated by User B", "residence_id": uint(residenceID), } - w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID), updateBody, userBToken) + w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID3), updateBody, userBToken) assert.Equal(t, http.StatusOK, w.Code, "User B should be able to update contractor in shared residence") // User C (without access) cannot update the contractor @@ -277,15 +280,15 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) { "name": "Hacked by User C", "residence_id": uint(residenceID), } - w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID), updateBody2, userCToken) + w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID3), updateBody2, userCToken) assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to update contractor") // User C cannot delete the contractor - w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID), nil, userCToken) + w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID3), nil, userCToken) assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to delete contractor") // User B (with access) can delete the contractor - w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID), nil, userBToken) + w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID3), nil, userBToken) assert.Equal(t, http.StatusOK, w.Code, "User B should be able to delete contractor in shared residence") } diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index f5d4d25..2cdee99 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -125,7 +125,6 @@ func setupIntegrationTest(t *testing.T) *TestApp { api.GET("/task-categories", taskHandler.GetCategories) api.GET("/task-priorities", taskHandler.GetPriorities) - api.GET("/task-statuses", taskHandler.GetStatuses) api.GET("/task-frequencies", taskHandler.GetFrequencies) } @@ -334,10 +333,11 @@ func TestIntegration_ResidenceFlow(t *testing.T) { var createResp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &createResp) require.NoError(t, err) - residenceID := createResp["id"].(float64) + createData := createResp["data"].(map[string]interface{}) + residenceID := createData["id"].(float64) assert.NotZero(t, residenceID) - assert.Equal(t, "My House", createResp["name"]) - assert.True(t, createResp["is_primary"].(bool)) + assert.Equal(t, "My House", createData["name"]) + assert.True(t, createData["is_primary"].(bool)) // 2. Get the residence w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, token) @@ -368,8 +368,9 @@ func TestIntegration_ResidenceFlow(t *testing.T) { var updateResp map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &updateResp) require.NoError(t, err) - assert.Equal(t, "My Updated House", updateResp["name"]) - assert.Equal(t, "Dallas", updateResp["city"]) + updateData := updateResp["data"].(map[string]interface{}) + assert.Equal(t, "My Updated House", updateData["name"]) + assert.Equal(t, "Dallas", updateData["city"]) // 5. Delete the residence (returns 200 with message, not 204) w = app.makeAuthenticatedRequest(t, "DELETE", "/api/residences/"+formatID(residenceID), nil, token) @@ -396,7 +397,8 @@ func TestIntegration_ResidenceSharingFlow(t *testing.T) { var createResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &createResp) - residenceID := createResp["id"].(float64) + createData := createResp["data"].(map[string]interface{}) + residenceID := createData["id"].(float64) // Other user cannot access initially w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, userToken) @@ -448,7 +450,8 @@ func TestIntegration_TaskFlow(t *testing.T) { var residenceResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &residenceResp) - residenceID := uint(residenceResp["id"].(float64)) + residenceData := residenceResp["data"].(map[string]interface{}) + residenceID := uint(residenceData["id"].(float64)) // 1. Create a task taskBody := map[string]interface{}{ @@ -461,9 +464,10 @@ func TestIntegration_TaskFlow(t *testing.T) { var taskResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskResp) - taskID := taskResp["id"].(float64) + taskData := taskResp["data"].(map[string]interface{}) + taskID := taskData["id"].(float64) assert.NotZero(t, taskID) - assert.Equal(t, "Fix leaky faucet", taskResp["title"]) + assert.Equal(t, "Fix leaky faucet", taskData["title"]) // 2. Get the task w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/"+formatID(taskID), nil, token) @@ -477,9 +481,10 @@ func TestIntegration_TaskFlow(t *testing.T) { w = app.makeAuthenticatedRequest(t, "PUT", "/api/tasks/"+formatID(taskID), updateBody, token) assert.Equal(t, http.StatusOK, w.Code) - var updateResp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &updateResp) - assert.Equal(t, "Fix kitchen faucet", updateResp["title"]) + var taskUpdateResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &taskUpdateResp) + taskUpdateData := taskUpdateResp["data"].(map[string]interface{}) + assert.Equal(t, "Fix kitchen faucet", taskUpdateData["title"]) // 4. Mark as in progress w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/mark-in-progress", nil, token) @@ -487,9 +492,8 @@ func TestIntegration_TaskFlow(t *testing.T) { var progressResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &progressResp) - task := progressResp["task"].(map[string]interface{}) - status := task["status"].(map[string]interface{}) - assert.Equal(t, "In Progress", status["name"]) + progressData := progressResp["data"].(map[string]interface{}) + assert.True(t, progressData["in_progress"].(bool)) // 5. Complete the task completionBody := map[string]interface{}{ @@ -501,9 +505,10 @@ func TestIntegration_TaskFlow(t *testing.T) { var completionResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &completionResp) - completionID := completionResp["id"].(float64) + completionData := completionResp["data"].(map[string]interface{}) + completionID := completionData["id"].(float64) assert.NotZero(t, completionID) - assert.Equal(t, "Fixed the faucet", completionResp["notes"]) + assert.Equal(t, "Fixed the faucet", completionData["notes"]) // 6. List completions w = app.makeAuthenticatedRequest(t, "GET", "/api/completions?task_id="+formatID(taskID), nil, token) @@ -515,8 +520,8 @@ func TestIntegration_TaskFlow(t *testing.T) { var archiveResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &archiveResp) - archivedTask := archiveResp["task"].(map[string]interface{}) - assert.True(t, archivedTask["is_archived"].(bool)) + archivedData := archiveResp["data"].(map[string]interface{}) + assert.True(t, archivedData["is_archived"].(bool)) // 8. Unarchive the task w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/unarchive", nil, token) @@ -528,8 +533,8 @@ func TestIntegration_TaskFlow(t *testing.T) { var cancelResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &cancelResp) - cancelledTask := cancelResp["task"].(map[string]interface{}) - assert.True(t, cancelledTask["is_cancelled"].(bool)) + cancelledData := cancelResp["data"].(map[string]interface{}) + assert.True(t, cancelledData["is_cancelled"].(bool)) // 10. Delete the task (returns 200 with message, not 204) w = app.makeAuthenticatedRequest(t, "DELETE", "/api/tasks/"+formatID(taskID), nil, token) @@ -547,7 +552,8 @@ func TestIntegration_TasksByResidenceKanban(t *testing.T) { var residenceResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &residenceResp) - residenceID := uint(residenceResp["id"].(float64)) + residenceData := residenceResp["data"].(map[string]interface{}) + residenceID := uint(residenceData["id"].(float64)) // Create multiple tasks for i := 1; i <= 3; i++ { @@ -592,7 +598,6 @@ func TestIntegration_LookupEndpoints(t *testing.T) { {"residence types", "/api/residence-types"}, {"task categories", "/api/task-categories"}, {"task priorities", "/api/task-priorities"}, - {"task statuses", "/api/task-statuses"}, {"task frequencies", "/api/task-frequencies"}, } @@ -633,7 +638,8 @@ func TestIntegration_CrossUserAccessDenied(t *testing.T) { var residenceResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &residenceResp) - residenceID := residenceResp["id"].(float64) + residenceData := residenceResp["data"].(map[string]interface{}) + residenceID := residenceData["id"].(float64) // User1 creates a task taskBody := map[string]interface{}{ @@ -645,7 +651,8 @@ func TestIntegration_CrossUserAccessDenied(t *testing.T) { var taskResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskResp) - taskID := taskResp["id"].(float64) + taskData := taskResp["data"].(map[string]interface{}) + taskID := taskData["id"].(float64) // User2 cannot access User1's residence w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, user2Token) @@ -693,7 +700,12 @@ func TestIntegration_ResponseStructure(t *testing.T) { var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) - // Verify all expected fields are present + // Response is wrapped with "data" and "summary" + data := resp["data"].(map[string]interface{}) + _, hasSummary := resp["summary"] + assert.True(t, hasSummary, "Expected 'summary' field in response") + + // Verify all expected fields are present in data expectedFields := []string{ "id", "owner_id", "name", "street_address", "city", "state_province", "postal_code", "country", @@ -701,13 +713,13 @@ func TestIntegration_ResponseStructure(t *testing.T) { } for _, field := range expectedFields { - _, exists := resp[field] - assert.True(t, exists, "Expected field %s to be present", field) + _, exists := data[field] + assert.True(t, exists, "Expected field %s to be present in data", field) } // Check that nullable fields can be null - assert.Nil(t, resp["bedrooms"]) - assert.Nil(t, resp["bathrooms"]) + assert.Nil(t, data["bedrooms"]) + assert.Nil(t, data["bathrooms"]) } // ============ Helper Functions ============ diff --git a/internal/models/task.go b/internal/models/task.go index 7983f95..bc7e46e 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -35,20 +35,6 @@ func (TaskPriority) TableName() string { return "task_taskpriority" } -// TaskStatus represents the task_taskstatus table -type TaskStatus struct { - BaseModel - Name string `gorm:"column:name;size:20;not null" json:"name"` - Description string `gorm:"column:description;type:text" json:"description"` - Color string `gorm:"column:color;size:7" json:"color"` - DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"` -} - -// TableName returns the table name for GORM -func (TaskStatus) TableName() string { - return "task_taskstatus" -} - // TaskFrequency represents the task_taskfrequency table type TaskFrequency struct { BaseModel @@ -79,11 +65,12 @@ type Task struct { Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` PriorityID *uint `gorm:"column:priority_id;index" json:"priority_id"` Priority *TaskPriority `gorm:"foreignKey:PriorityID" json:"priority,omitempty"` - StatusID *uint `gorm:"column:status_id;index" json:"status_id"` - Status *TaskStatus `gorm:"foreignKey:StatusID" json:"status,omitempty"` FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"` Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"` + // In Progress flag - replaces status lookup + InProgress bool `gorm:"column:in_progress;default:false;index" json:"in_progress"` + DueDate *time.Time `gorm:"column:due_date;type:date;index" json:"due_date"` NextDueDate *time.Time `gorm:"column:next_due_date;type:date;index" json:"next_due_date"` // For recurring tasks, updated after each completion EstimatedCost *decimal.Decimal `gorm:"column:estimated_cost;type:decimal(10,2)" json:"estimated_cost"` diff --git a/internal/models/task_test.go b/internal/models/task_test.go index 0adb263..3e49792 100644 --- a/internal/models/task_test.go +++ b/internal/models/task_test.go @@ -24,11 +24,6 @@ func TestTaskPriority_TableName(t *testing.T) { assert.Equal(t, "task_taskpriority", p.TableName()) } -func TestTaskStatus_TableName(t *testing.T) { - s := TaskStatus{} - assert.Equal(t, "task_taskstatus", s.TableName()) -} - func TestTaskFrequency_TableName(t *testing.T) { f := TaskFrequency{} assert.Equal(t, "task_taskfrequency", f.TableName()) @@ -134,28 +129,6 @@ func TestTaskPriority_JSONSerialization(t *testing.T) { assert.Equal(t, "#e74c3c", result["color"]) } -func TestTaskStatus_JSONSerialization(t *testing.T) { - status := TaskStatus{ - Name: "In Progress", - Description: "Task is being worked on", - Color: "#3498db", - DisplayOrder: 2, - } - status.ID = 2 - - data, err := json.Marshal(status) - assert.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - assert.NoError(t, err) - - assert.Equal(t, float64(2), result["id"]) - assert.Equal(t, "In Progress", result["name"]) - assert.Equal(t, "Task is being worked on", result["description"]) - assert.Equal(t, "#3498db", result["color"]) -} - func TestTaskFrequency_JSONSerialization(t *testing.T) { days := 7 freq := TaskFrequency{ diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index 8f66c72..185fbdf 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -30,7 +30,6 @@ func (r *TaskRepository) FindByID(id uint) (*models.Task, error) { Preload("AssignedTo"). Preload("Category"). Preload("Priority"). - Preload("Status"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). @@ -49,7 +48,6 @@ func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error Preload("AssignedTo"). Preload("Category"). Preload("Priority"). - Preload("Status"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). @@ -68,7 +66,6 @@ func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models. Preload("AssignedTo"). Preload("Category"). Preload("Priority"). - Preload("Status"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). @@ -87,7 +84,7 @@ func (r *TaskRepository) Create(task *models.Task) error { // Update updates a task // Uses Omit to exclude associations that shouldn't be updated via Save func (r *TaskRepository) Update(task *models.Task) error { - return r.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Status", "Frequency", "ParentTask", "Completions").Save(task).Error + return r.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").Save(task).Error } // Delete hard-deletes a task @@ -98,10 +95,10 @@ func (r *TaskRepository) Delete(id uint) error { // === Task State Operations === // MarkInProgress marks a task as in progress -func (r *TaskRepository) MarkInProgress(id uint, statusID uint) error { +func (r *TaskRepository) MarkInProgress(id uint) error { return r.db.Model(&models.Task{}). Where("id = ?", id). - Update("status_id", statusID).Error + Update("in_progress", true).Error } // Cancel cancels a task @@ -142,7 +139,6 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo Preload("AssignedTo"). Preload("Category"). Preload("Priority"). - Preload("Status"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). @@ -229,7 +225,6 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, Preload("AssignedTo"). Preload("Category"). Preload("Priority"). - Preload("Status"). Preload("Frequency"). Preload("Completions"). Preload("Completions.Images"). @@ -325,13 +320,6 @@ func (r *TaskRepository) GetAllPriorities() ([]models.TaskPriority, error) { return priorities, err } -// GetAllStatuses returns all task statuses -func (r *TaskRepository) GetAllStatuses() ([]models.TaskStatus, error) { - var statuses []models.TaskStatus - err := r.db.Order("display_order").Find(&statuses).Error - return statuses, err -} - // GetAllFrequencies returns all task frequencies func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) { var frequencies []models.TaskFrequency @@ -339,16 +327,6 @@ func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) { return frequencies, err } -// FindStatusByName finds a status by name -func (r *TaskRepository) FindStatusByName(name string) (*models.TaskStatus, error) { - var status models.TaskStatus - err := r.db.Where("name = ?", name).First(&status).Error - if err != nil { - return nil, err - } - return &status, nil -} - // CountByResidence counts tasks in a residence func (r *TaskRepository) CountByResidence(residenceID uint) (int64, error) { var count int64 diff --git a/internal/repositories/task_repo_test.go b/internal/repositories/task_repo_test.go index 12432f7..e630afd 100644 --- a/internal/repositories/task_repo_test.go +++ b/internal/repositories/task_repo_test.go @@ -277,15 +277,6 @@ func TestTaskRepository_GetAllPriorities(t *testing.T) { assert.Greater(t, len(priorities), 0) } -func TestTaskRepository_GetAllStatuses(t *testing.T) { - db := testutil.SetupTestDB(t) - repo := NewTaskRepository(db) - testutil.SeedLookupData(t, db) - - statuses, err := repo.GetAllStatuses() - require.NoError(t, err) - assert.Greater(t, len(statuses), 0) -} func TestTaskRepository_GetAllFrequencies(t *testing.T) { db := testutil.SetupTestDB(t) @@ -396,16 +387,12 @@ func TestKanbanBoard_InProgressTasksGoToInProgressColumn(t *testing.T) { user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") - // Get "In Progress" status - var inProgressStatus models.TaskStatus - db.Where("name = ?", "In Progress").First(&inProgressStatus) - - // Create a task with "In Progress" status + // Create a task with InProgress = true task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "In Progress Task", - StatusID: &inProgressStatus.ID, + InProgress: true, } err := db.Create(task).Error require.NoError(t, err) @@ -654,17 +641,13 @@ func TestKanbanBoard_CategoryPriority_CompletedTakesPrecedenceOverInProgress(t * user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") - // Get "In Progress" status - var inProgressStatus models.TaskStatus - db.Where("name = ?", "In Progress").First(&inProgressStatus) - - // Create a task that has "In Progress" status AND a completion + // Create a task that has InProgress = true AND a completion // Completed should take precedence task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "In Progress with Completion", - StatusID: &inProgressStatus.ID, + InProgress: true, } err := db.Create(task).Error require.NoError(t, err) diff --git a/internal/router/router.go b/internal/router/router.go index 753edef..a698651 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -251,7 +251,6 @@ func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resi api.GET("/tasks/categories/", taskHandler.GetCategories) api.GET("/tasks/priorities/", taskHandler.GetPriorities) api.GET("/tasks/frequencies/", taskHandler.GetFrequencies) - api.GET("/tasks/statuses/", taskHandler.GetStatuses) api.GET("/contractors/specialties/", contractorHandler.GetSpecialties) // Task template routes (public, for app autocomplete) diff --git a/internal/services/cache_service.go b/internal/services/cache_service.go index 83afb2f..cdb66f7 100644 --- a/internal/services/cache_service.go +++ b/internal/services/cache_service.go @@ -168,7 +168,6 @@ const ( LookupKeyPrefix = "lookup:" LookupCategoriesKey = LookupKeyPrefix + "categories" LookupPrioritiesKey = LookupKeyPrefix + "priorities" - LookupStatusesKey = LookupKeyPrefix + "statuses" LookupFrequenciesKey = LookupKeyPrefix + "frequencies" LookupResidenceTypesKey = LookupKeyPrefix + "residence_types" LookupSpecialtiesKey = LookupKeyPrefix + "specialties" @@ -196,7 +195,6 @@ func (c *CacheService) InvalidateAllLookups(ctx context.Context) error { keys := []string{ LookupCategoriesKey, LookupPrioritiesKey, - LookupStatusesKey, LookupFrequenciesKey, LookupResidenceTypesKey, LookupSpecialtiesKey, @@ -239,21 +237,6 @@ func (c *CacheService) InvalidatePriorities(ctx context.Context) error { return c.Delete(ctx, LookupPrioritiesKey, StaticDataKey) } -// CacheStatuses caches task statuses -func (c *CacheService) CacheStatuses(ctx context.Context, data interface{}) error { - return c.CacheLookupData(ctx, LookupStatusesKey, data) -} - -// GetCachedStatuses retrieves cached task statuses -func (c *CacheService) GetCachedStatuses(ctx context.Context, dest interface{}) error { - return c.GetCachedLookupData(ctx, LookupStatusesKey, dest) -} - -// InvalidateStatuses removes cached task statuses -func (c *CacheService) InvalidateStatuses(ctx context.Context) error { - return c.Delete(ctx, LookupStatusesKey, StaticDataKey) -} - // CacheFrequencies caches task frequencies func (c *CacheService) CacheFrequencies(ctx context.Context, data interface{}) error { return c.CacheLookupData(ctx, LookupFrequenciesKey, data) diff --git a/internal/services/residence_service.go b/internal/services/residence_service.go index f7469d1..5ef9978 100644 --- a/internal/services/residence_service.go +++ b/internal/services/residence_service.go @@ -616,8 +616,8 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks if task.Priority != nil { taskData.Priority = task.Priority.Name } - if task.Status != nil { - taskData.Status = task.Status.Name + if task.InProgress { + taskData.Status = "In Progress" } // Use effective date for report (NextDueDate ?? DueDate) effectiveDate := predicates.EffectiveDate(&task) diff --git a/internal/services/residence_service_test.go b/internal/services/residence_service_test.go index fcd184a..f7ee179 100644 --- a/internal/services/residence_service_test.go +++ b/internal/services/residence_service_test.go @@ -42,12 +42,12 @@ func TestResidenceService_CreateResidence(t *testing.T) { resp, err := service.CreateResidence(req, user.ID) require.NoError(t, err) assert.NotNil(t, resp) - assert.Equal(t, "Test House", resp.Name) - assert.Equal(t, "123 Main St", resp.StreetAddress) - assert.Equal(t, "Austin", resp.City) - assert.Equal(t, "TX", resp.StateProvince) - assert.Equal(t, "USA", resp.Country) // Default country - assert.True(t, resp.IsPrimary) // Default is_primary + assert.Equal(t, "Test House", resp.Data.Name) + assert.Equal(t, "123 Main St", resp.Data.StreetAddress) + assert.Equal(t, "Austin", resp.Data.City) + assert.Equal(t, "TX", resp.Data.StateProvince) + assert.Equal(t, "USA", resp.Data.Country) // Default country + assert.True(t, resp.Data.IsPrimary) // Default is_primary } func TestResidenceService_CreateResidence_WithOptionalFields(t *testing.T) { @@ -79,12 +79,12 @@ func TestResidenceService_CreateResidence_WithOptionalFields(t *testing.T) { resp, err := service.CreateResidence(req, user.ID) require.NoError(t, err) - assert.Equal(t, "Canada", resp.Country) - assert.Equal(t, 3, *resp.Bedrooms) - assert.True(t, resp.Bathrooms.Equal(decimal.NewFromFloat(2.5))) - assert.Equal(t, 2000, *resp.SquareFootage) + assert.Equal(t, "Canada", resp.Data.Country) + assert.Equal(t, 3, *resp.Data.Bedrooms) + assert.True(t, resp.Data.Bathrooms.Equal(decimal.NewFromFloat(2.5))) + assert.Equal(t, 2000, *resp.Data.SquareFootage) // First residence defaults to primary regardless of request - assert.True(t, resp.IsPrimary) + assert.True(t, resp.Data.IsPrimary) } func TestResidenceService_GetResidence(t *testing.T) { @@ -166,8 +166,8 @@ func TestResidenceService_UpdateResidence(t *testing.T) { resp, err := service.UpdateResidence(residence.ID, user.ID, req) require.NoError(t, err) - assert.Equal(t, "Updated Name", resp.Name) - assert.Equal(t, "Dallas", resp.City) + assert.Equal(t, "Updated Name", resp.Data.Name) + assert.Equal(t, "Dallas", resp.Data.City) } func TestResidenceService_UpdateResidence_NotOwner(t *testing.T) { @@ -201,7 +201,7 @@ func TestResidenceService_DeleteResidence(t *testing.T) { user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") - err := service.DeleteResidence(residence.ID, user.ID) + _, err := service.DeleteResidence(residence.ID, user.ID) require.NoError(t, err) // Should not be found @@ -221,7 +221,7 @@ func TestResidenceService_DeleteResidence_NotOwner(t *testing.T) { residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") residenceRepo.AddUser(residence.ID, sharedUser.ID) - err := service.DeleteResidence(residence.ID, sharedUser.ID) + _, err := service.DeleteResidence(residence.ID, sharedUser.ID) assert.ErrorIs(t, err, ErrNotResidenceOwner) } diff --git a/internal/services/task_categorization_test.go b/internal/services/task_categorization_test.go index 2f35563..0276989 100644 --- a/internal/services/task_categorization_test.go +++ b/internal/services/task_categorization_test.go @@ -33,7 +33,7 @@ KANBAN COLUMNS (in priority order): ---------------------------------- 1. CANCELLED: Task.IsCancelled = true 2. COMPLETED: NextDueDate = nil AND has completions (one-time task done) -3. IN_PROGRESS: Status.Name = "In Progress" +3. IN_PROGRESS: InProgress = true 4. OVERDUE: NextDueDate < now 5. DUE_SOON: NextDueDate < now + daysThreshold (default 30) 6. UPCOMING: Everything else (NextDueDate >= threshold or no due date) @@ -72,6 +72,14 @@ func daysAgo(n int) time.Time { return time.Now().UTC().AddDate(0, 0, -n) } +// isTaskCompleted checks if a task is permanently completed (one-time task done). +// A task is completed when it has completions AND NextDueDate is nil. +func isTaskCompleted(task *models.Task) bool { + if len(task.Completions) == 0 { + return false + } + return task.NextDueDate == nil +} // ============================================================================ // isTaskCompleted FUNCTION TESTS @@ -157,7 +165,7 @@ func TestGetButtonTypesForTask_CompletedOneTimeTask(t *testing.T) { func TestGetButtonTypesForTask_InProgressTask(t *testing.T) { task := &models.Task{ NextDueDate: ptr(daysFromNow(10)), - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, } buttons := GetButtonTypesForTask(task, 30) @@ -237,7 +245,7 @@ func TestGetIOSCategoryForTask_CompletedTask(t *testing.T) { func TestGetIOSCategoryForTask_InProgressTask(t *testing.T) { task := &models.Task{ NextDueDate: ptr(daysFromNow(10)), - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, } category := GetIOSCategoryForTask(task) @@ -285,7 +293,7 @@ func TestDetermineKanbanColumn_CompletedOneTimeTask(t *testing.T) { func TestDetermineKanbanColumn_InProgressTask(t *testing.T) { task := &models.Task{ NextDueDate: ptr(daysAgo(5)), // Even overdue - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, } column := responses.DetermineKanbanColumn(task, 30) @@ -902,7 +910,7 @@ func TestEdgeCase_CancelledAndOverdue(t *testing.T) { func TestEdgeCase_InProgressAndOverdue(t *testing.T) { task := &models.Task{ NextDueDate: ptr(daysAgo(5)), - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, } column := responses.DetermineKanbanColumn(task, 30) @@ -1011,7 +1019,7 @@ func TestButtonTypes_ConsistencyWithKanbanColumn(t *testing.T) { name: "In Progress task", task: &models.Task{ NextDueDate: ptr(daysFromNow(10)), - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, }, expectedColumn: "in_progress_tasks", expectedButtons: []string{"edit", "complete", "cancel"}, @@ -1062,7 +1070,7 @@ func TestPriorityOrder_CancelledBeatsEverything(t *testing.T) { task := &models.Task{ IsCancelled: true, NextDueDate: ptr(daysAgo(10)), - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}}, } @@ -1074,7 +1082,7 @@ func TestPriorityOrder_CompletedBeatsInProgress(t *testing.T) { // One-time task with In Progress status but completed task := &models.Task{ NextDueDate: nil, - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}}, } @@ -1086,7 +1094,7 @@ func TestPriorityOrder_InProgressBeatsDateBased(t *testing.T) { // Overdue task that's in progress task := &models.Task{ NextDueDate: ptr(daysAgo(10)), - Status: &models.TaskStatus{Name: "In Progress"}, + InProgress: true, } column := responses.DetermineKanbanColumn(task, 30) diff --git a/internal/services/task_service.go b/internal/services/task_service.go index 0ca2053..48cb031 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -173,8 +173,8 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) ( Description: req.Description, CategoryID: req.CategoryID, PriorityID: req.PriorityID, - StatusID: req.StatusID, FrequencyID: req.FrequencyID, + InProgress: req.InProgress, AssignedToID: req.AssignedToID, DueDate: dueDate, NextDueDate: dueDate, // Initialize next_due_date to due_date @@ -230,12 +230,12 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe if req.PriorityID != nil { task.PriorityID = req.PriorityID } - if req.StatusID != nil { - task.StatusID = req.StatusID - } if req.FrequencyID != nil { task.FrequencyID = req.FrequencyID } + if req.InProgress != nil { + task.InProgress = *req.InProgress + } if req.AssignedToID != nil { task.AssignedToID = req.AssignedToID } @@ -324,13 +324,7 @@ func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSu return nil, ErrTaskAccessDenied } - // Find "In Progress" status - status, err := s.taskRepo.FindStatusByName("In Progress") - if err != nil { - return nil, err - } - - if err := s.taskRepo.MarkInProgress(taskID, status.ID); err != nil { + if err := s.taskRepo.MarkInProgress(taskID); err != nil { return nil, err } @@ -534,24 +528,22 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest return nil, err } - // Update next_due_date and status based on frequency - // - If frequency is "Once" (days = nil or 0), set next_due_date to nil and status to "Completed" + // Update next_due_date and in_progress based on frequency + // - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed) // - If frequency is recurring, calculate next_due_date = completion_date + frequency_days - // and reset status to "Pending" so task shows in correct kanban column + // and reset in_progress to false so task shows in correct kanban column if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 { - // One-time task - clear next_due_date and set status to "Completed" (ID=3) + // One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions) task.NextDueDate = nil - completedStatusID := uint(3) - task.StatusID = &completedStatusID + task.InProgress = false } else { // Recurring task - calculate next due date from completion date + frequency nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days) task.NextDueDate = &nextDue - // Reset status to "Pending" (ID=1) so task appears in upcoming/due_soon + // Reset in_progress to false so task appears in upcoming/due_soon // instead of staying in "In Progress" column - pendingStatusID := uint(1) - task.StatusID = &pendingStatusID + task.InProgress = false } if err := s.taskRepo.Update(task); err != nil { log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion") @@ -633,20 +625,18 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error { return err } - // Update next_due_date and status based on frequency + // Update next_due_date and in_progress based on frequency if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 { - // One-time task - clear next_due_date and set status to "Completed" (ID=3) + // One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions) task.NextDueDate = nil - completedStatusID := uint(3) - task.StatusID = &completedStatusID + task.InProgress = false } else { // Recurring task - calculate next due date from completion date + frequency nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days) task.NextDueDate = &nextDue - // Reset status to "Pending" (ID=1) - pendingStatusID := uint(1) - task.StatusID = &pendingStatusID + // Reset in_progress to false + task.InProgress = false } if err := s.taskRepo.Update(task); err != nil { log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after quick completion") @@ -858,20 +848,6 @@ func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error) return result, nil } -// GetStatuses returns all task statuses -func (s *TaskService) GetStatuses() ([]responses.TaskStatusResponse, error) { - statuses, err := s.taskRepo.GetAllStatuses() - if err != nil { - return nil, err - } - - result := make([]responses.TaskStatusResponse, len(statuses)) - for i, st := range statuses { - result[i] = *responses.NewTaskStatusResponse(&st) - } - return result, nil -} - // GetFrequencies returns all task frequencies func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) { frequencies, err := s.taskRepo.GetAllFrequencies() diff --git a/internal/services/task_service_test.go b/internal/services/task_service_test.go index a81288d..b358c2e 100644 --- a/internal/services/task_service_test.go +++ b/internal/services/task_service_test.go @@ -41,9 +41,9 @@ func TestTaskService_CreateTask(t *testing.T) { resp, err := service.CreateTask(req, user.ID) require.NoError(t, err) - assert.NotZero(t, resp.ID) - assert.Equal(t, "Fix leaky faucet", resp.Title) - assert.Equal(t, "Kitchen faucet is dripping", resp.Description) + assert.NotZero(t, resp.Data.ID) + assert.Equal(t, "Fix leaky faucet", resp.Data.Title) + assert.Equal(t, "Kitchen faucet is dripping", resp.Data.Description) } func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) { @@ -76,10 +76,10 @@ func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) { resp, err := service.CreateTask(req, user.ID) require.NoError(t, err) - assert.NotNil(t, resp.Category) - assert.NotNil(t, resp.Priority) - assert.NotNil(t, resp.DueDate) - assert.NotNil(t, resp.EstimatedCost) + assert.NotNil(t, resp.Data.Category) + assert.NotNil(t, resp.Data.Priority) + assert.NotNil(t, resp.Data.DueDate) + assert.NotNil(t, resp.Data.EstimatedCost) } func TestTaskService_CreateTask_AccessDenied(t *testing.T) { @@ -180,8 +180,8 @@ func TestTaskService_UpdateTask(t *testing.T) { resp, err := service.UpdateTask(task.ID, user.ID, req) require.NoError(t, err) - assert.Equal(t, "Updated Title", resp.Title) - assert.Equal(t, "Updated description", resp.Description) + assert.Equal(t, "Updated Title", resp.Data.Title) + assert.Equal(t, "Updated description", resp.Data.Description) } func TestTaskService_DeleteTask(t *testing.T) { @@ -195,7 +195,7 @@ func TestTaskService_DeleteTask(t *testing.T) { residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") - err := service.DeleteTask(task.ID, user.ID) + _, err := service.DeleteTask(task.ID, user.ID) require.NoError(t, err) _, err = service.GetTask(task.ID, user.ID) @@ -215,7 +215,7 @@ func TestTaskService_CancelTask(t *testing.T) { resp, err := service.CancelTask(task.ID, user.ID) require.NoError(t, err) - assert.True(t, resp.IsCancelled) + assert.True(t, resp.Data.IsCancelled) } func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) { @@ -248,7 +248,7 @@ func TestTaskService_UncancelTask(t *testing.T) { service.CancelTask(task.ID, user.ID) resp, err := service.UncancelTask(task.ID, user.ID) require.NoError(t, err) - assert.False(t, resp.IsCancelled) + assert.False(t, resp.Data.IsCancelled) } func TestTaskService_ArchiveTask(t *testing.T) { @@ -264,7 +264,7 @@ func TestTaskService_ArchiveTask(t *testing.T) { resp, err := service.ArchiveTask(task.ID, user.ID) require.NoError(t, err) - assert.True(t, resp.IsArchived) + assert.True(t, resp.Data.IsArchived) } func TestTaskService_UnarchiveTask(t *testing.T) { @@ -281,7 +281,7 @@ func TestTaskService_UnarchiveTask(t *testing.T) { service.ArchiveTask(task.ID, user.ID) resp, err := service.UnarchiveTask(task.ID, user.ID) require.NoError(t, err) - assert.False(t, resp.IsArchived) + assert.False(t, resp.Data.IsArchived) } func TestTaskService_MarkInProgress(t *testing.T) { @@ -297,8 +297,7 @@ func TestTaskService_MarkInProgress(t *testing.T) { resp, err := service.MarkInProgress(task.ID, user.ID) require.NoError(t, err) - assert.NotNil(t, resp.Status) - assert.Equal(t, "In Progress", resp.Status.Name) + assert.True(t, resp.Data.InProgress) } func TestTaskService_CreateCompletion(t *testing.T) { @@ -319,12 +318,12 @@ func TestTaskService_CreateCompletion(t *testing.T) { resp, err := service.CreateCompletion(req, user.ID) require.NoError(t, err) - assert.NotZero(t, resp.ID) - assert.Equal(t, task.ID, resp.TaskID) - assert.Equal(t, "Completed successfully", resp.Notes) + assert.NotZero(t, resp.Data.ID) + assert.Equal(t, task.ID, resp.Data.TaskID) + assert.Equal(t, "Completed successfully", resp.Data.Notes) } -func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *testing.T) { +func TestTaskService_CreateCompletion_RecurringTask_ResetsInProgress(t *testing.T) { db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) taskRepo := repositories.NewTaskRepository(db) @@ -334,20 +333,16 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *tes user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") - // Get the "In Progress" status (ID=2) and a recurring frequency - var inProgressStatus models.TaskStatus - db.Where("name = ?", "In Progress").First(&inProgressStatus) - var monthlyFrequency models.TaskFrequency db.Where("name = ?", "Monthly").First(&monthlyFrequency) - // Create a recurring task with "In Progress" status + // Create a recurring task that is in progress dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days task := &models.Task{ ResidenceID: residence.ID, CreatedByID: user.ID, Title: "Recurring Task", - StatusID: &inProgressStatus.ID, + InProgress: true, FrequencyID: &monthlyFrequency.ID, DueDate: &dueDate, NextDueDate: &dueDate, @@ -365,24 +360,21 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *tes resp, err := service.CreateCompletion(req, user.ID) require.NoError(t, err) - assert.NotZero(t, resp.ID) + assert.NotZero(t, resp.Data.ID) - // Verify the task in the response has status reset to "Pending" (ID=1) - require.NotNil(t, resp.Task, "Response should include the updated task") - require.NotNil(t, resp.Task.StatusID, "Task should have a status ID") - assert.Equal(t, uint(1), *resp.Task.StatusID, "Recurring task status should be reset to Pending (ID=1) after completion") + // Verify the task in the response has InProgress reset to false + require.NotNil(t, resp.Data.Task, "Response should include the updated task") + assert.False(t, resp.Data.Task.InProgress, "Recurring task InProgress should be reset to false after completion") // Verify NextDueDate was updated (should be ~30 days from now for monthly) - require.NotNil(t, resp.Task.NextDueDate, "Recurring task should have NextDueDate set") + require.NotNil(t, resp.Data.Task.NextDueDate, "Recurring task should have NextDueDate set") expectedNextDue := time.Now().AddDate(0, 0, 30) // Monthly = 30 days - assert.WithinDuration(t, expectedNextDue, *resp.Task.NextDueDate, 24*time.Hour, "NextDueDate should be approximately 30 days from now") + assert.WithinDuration(t, expectedNextDue, *resp.Data.Task.NextDueDate, 24*time.Hour, "NextDueDate should be approximately 30 days from now") // Also verify by reloading from database directly var reloadedTask models.Task - db.Preload("Status").First(&reloadedTask, task.ID) - require.NotNil(t, reloadedTask.StatusID) - assert.Equal(t, uint(1), *reloadedTask.StatusID, "Database should show Pending status") - assert.Equal(t, "Pending", reloadedTask.Status.Name) + db.First(&reloadedTask, task.ID) + assert.False(t, reloadedTask.InProgress, "Database should show InProgress=false") } func TestTaskService_GetCompletion(t *testing.T) { @@ -428,7 +420,7 @@ func TestTaskService_DeleteCompletion(t *testing.T) { } db.Create(completion) - err := service.DeleteCompletion(completion.ID, user.ID) + _, err := service.DeleteCompletion(completion.ID, user.ID) require.NoError(t, err) _, err = service.GetCompletion(completion.ID, user.ID) @@ -470,18 +462,6 @@ func TestTaskService_GetPriorities(t *testing.T) { } } -func TestTaskService_GetStatuses(t *testing.T) { - db := testutil.SetupTestDB(t) - testutil.SeedLookupData(t, db) - taskRepo := repositories.NewTaskRepository(db) - residenceRepo := repositories.NewResidenceRepository(db) - service := NewTaskService(taskRepo, residenceRepo) - - statuses, err := service.GetStatuses() - require.NoError(t, err) - assert.Greater(t, len(statuses), 0) -} - func TestTaskService_GetFrequencies(t *testing.T) { db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) diff --git a/internal/task/categorization/chain_test.go b/internal/task/categorization/chain_test.go index 2aaa5b6..b460832 100644 --- a/internal/task/categorization/chain_test.go +++ b/internal/task/categorization/chain_test.go @@ -18,7 +18,6 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) { yesterday := now.AddDate(0, 0, -1) in5Days := now.AddDate(0, 0, 5) in60Days := now.AddDate(0, 0, 60) - inProgressStatus := &models.TaskStatus{Name: "In Progress"} daysThreshold := 30 tests := []struct { @@ -32,7 +31,7 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) { task: &models.Task{ IsCancelled: true, NextDueDate: timePtr(yesterday), // Would be overdue - Status: inProgressStatus, // Would be in progress + InProgress: true, // Would be in progress Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil }, expected: categorization.ColumnCancelled, @@ -68,7 +67,7 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) { IsCancelled: false, IsArchived: false, NextDueDate: timePtr(yesterday), // Would be overdue - Status: inProgressStatus, + InProgress: true, Completions: []models.TaskCompletion{}, }, expected: categorization.ColumnInProgress, @@ -151,13 +150,13 @@ func TestCategorizeTasksIntoColumns(t *testing.T) { daysThreshold := 30 tasks := []models.Task{ - {BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled - {BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed - {BaseModel: models.BaseModel{ID: 3}, Status: &models.TaskStatus{Name: "In Progress"}}, // In Progress - {BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue - {BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon - {BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming - {BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date) + {BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled + {BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed + {BaseModel: models.BaseModel{ID: 3}, InProgress: true}, // In Progress + {BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue + {BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon + {BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming + {BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date) } result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold) diff --git a/internal/task/consistency_test.go b/internal/task/consistency_test.go index 88b94ea..ff3252d 100644 --- a/internal/task/consistency_test.go +++ b/internal/task/consistency_test.go @@ -103,15 +103,6 @@ func createCompletion(t *testing.T, taskID uint) { } } -// getInProgressStatusID returns the ID of the "In Progress" status -func getInProgressStatusID(t *testing.T) *uint { - var status models.TaskStatus - if err := testDB.Where("name = ?", "In Progress").First(&status).Error; err != nil { - t.Logf("In Progress status not found, skipping in-progress tests") - return nil - } - return &status.ID -} // TaskTestCase defines a test scenario with expected categorization type TaskTestCase struct { @@ -147,8 +138,6 @@ func TestAllThreeLayersMatch(t *testing.T) { in60Days := now.AddDate(0, 0, 60) daysThreshold := 30 - inProgressStatusID := getInProgressStatusID(t) - // Define all test cases with expected results for each layer testCases := []TaskTestCase{ { @@ -293,27 +282,23 @@ func TestAllThreeLayersMatch(t *testing.T) { ExpectDueSoon: false, ExpectUpcoming: false, }, - } - - // Add in-progress test case only if status exists - if inProgressStatusID != nil { - testCases = append(testCases, TaskTestCase{ + { Name: "in_progress_overdue", Task: &models.Task{ Title: "in_progress_overdue", NextDueDate: timePtr(yesterday), // Would be overdue - StatusID: inProgressStatusID, + InProgress: true, IsCancelled: false, IsArchived: false, }, ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority ExpectCompleted: false, ExpectActive: true, - ExpectOverdue: true, // Predicate says overdue (doesn't check status) + ExpectOverdue: true, // Predicate says overdue (doesn't check InProgress) ExpectDueSoon: false, ExpectUpcoming: false, ExpectInProgress: true, - }) + }, } // Create all tasks in database @@ -330,7 +315,6 @@ func TestAllThreeLayersMatch(t *testing.T) { var allTasks []models.Task err := testDB. Preload("Completions"). - Preload("Status"). Where("residence_id = ?", residenceID). Find(&allTasks).Error if err != nil { @@ -490,26 +474,24 @@ func TestAllThreeLayersMatch(t *testing.T) { } }) - // Test ScopeInProgress (if status exists) - if inProgressStatusID != nil { - t.Run("ScopeInProgress", func(t *testing.T) { - var scopeResults []models.Task - testDB.Model(&models.Task{}). - Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress). - Find(&scopeResults) + // Test ScopeInProgress + t.Run("ScopeInProgress", func(t *testing.T) { + var scopeResults []models.Task + testDB.Model(&models.Task{}). + Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress). + Find(&scopeResults) - predicateCount := 0 - for _, task := range allTasks { - if predicates.IsInProgress(&task) { - predicateCount++ - } + predicateCount := 0 + for _, task := range allTasks { + if predicates.IsInProgress(&task) { + predicateCount++ } + } - if len(scopeResults) != predicateCount { - t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount) - } - }) - } + if len(scopeResults) != predicateCount { + t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount) + } + }) }) // ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ========== @@ -527,7 +509,6 @@ func TestAllThreeLayersMatch(t *testing.T) { t.Run("overdue_column", func(t *testing.T) { var scopeResults []models.Task testDB.Model(&models.Task{}). - Preload("Status"). Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)). Find(&scopeResults) @@ -612,7 +593,7 @@ func TestSameDayOverdueConsistency(t *testing.T) { // Reload with preloads var loadedTask models.Task - testDB.Preload("Completions").Preload("Status").First(&loadedTask, task.ID) + testDB.Preload("Completions").First(&loadedTask, task.ID) // All three layers should agree predicateResult := predicates.IsOverdue(&loadedTask, now) diff --git a/internal/task/predicates/predicates.go b/internal/task/predicates/predicates.go index e81f10a..157ee1a 100644 --- a/internal/task/predicates/predicates.go +++ b/internal/task/predicates/predicates.go @@ -60,13 +60,13 @@ func IsArchived(task *models.Task) bool { return task.IsArchived } -// IsInProgress returns true if the task has status "In Progress". +// IsInProgress returns true if the task is marked as in progress. // // SQL equivalent (in scopes.go ScopeInProgress): // -// task_taskstatus.name = 'In Progress' +// in_progress = true func IsInProgress(task *models.Task) bool { - return task.Status != nil && task.Status.Name == "In Progress" + return task.InProgress } // ============================================================================= diff --git a/internal/task/predicates/predicates_test.go b/internal/task/predicates/predicates_test.go index 24e7411..dbb8376 100644 --- a/internal/task/predicates/predicates_test.go +++ b/internal/task/predicates/predicates_test.go @@ -102,27 +102,19 @@ func TestIsActive(t *testing.T) { } func TestIsInProgress(t *testing.T) { - inProgressStatus := &models.TaskStatus{Name: "In Progress"} - pendingStatus := &models.TaskStatus{Name: "Pending"} - tests := []struct { name string task *models.Task expected bool }{ { - name: "in progress: status is In Progress", - task: &models.Task{Status: inProgressStatus}, + name: "in progress: InProgress is true", + task: &models.Task{InProgress: true}, expected: true, }, { - name: "not in progress: status is Pending", - task: &models.Task{Status: pendingStatus}, - expected: false, - }, - { - name: "not in progress: no status", - task: &models.Task{Status: nil}, + name: "not in progress: InProgress is false", + task: &models.Task{InProgress: false}, expected: false, }, } diff --git a/internal/task/scopes/scopes.go b/internal/task/scopes/scopes.go index c50037d..36cef6c 100644 --- a/internal/task/scopes/scopes.go +++ b/internal/task/scopes/scopes.go @@ -73,22 +73,22 @@ func ScopeNotCompleted(db *gorm.DB) *gorm.DB { ) } -// ScopeInProgress filters to tasks with status "In Progress". +// ScopeInProgress filters to tasks marked as in progress. // // Predicate equivalent: IsInProgress(task) // -// SQL: Joins task_taskstatus and filters by name = 'In Progress' +// SQL: in_progress = true func ScopeInProgress(db *gorm.DB) *gorm.DB { - return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). - Where("task_taskstatus.name = ?", "In Progress") + return db.Where("in_progress = ?", true) } -// ScopeNotInProgress excludes tasks with status "In Progress". +// ScopeNotInProgress excludes tasks marked as in progress. // // Predicate equivalent: !IsInProgress(task) +// +// SQL: in_progress = false func ScopeNotInProgress(db *gorm.DB) *gorm.DB { - return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id"). - Where("task_taskstatus.name != ? OR task_taskstatus.name IS NULL", "In Progress") + return db.Where("in_progress = ?", false) } // ============================================================================= diff --git a/internal/task/scopes/scopes_test.go b/internal/task/scopes/scopes_test.go index 3d6586e..1bccc30 100644 --- a/internal/task/scopes/scopes_test.go +++ b/internal/task/scopes/scopes_test.go @@ -54,7 +54,6 @@ func TestMain(m *testing.M) { err = testDB.AutoMigrate( &models.Task{}, &models.TaskCompletion{}, - &models.TaskStatus{}, &models.Residence{}, ) if err != nil { @@ -77,7 +76,6 @@ func cleanupTestData() { } testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'test_%')") testDB.Exec("DELETE FROM task_task WHERE title LIKE 'test_%'") - testDB.Exec("DELETE FROM task_taskstatus WHERE name LIKE 'test_%'") testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'test_%'") } @@ -102,16 +100,6 @@ func createTestResidence(t *testing.T) uint { return residence.ID } -// createTestStatus creates a test status and returns it -func createTestStatus(t *testing.T, name string) *models.TaskStatus { - status := &models.TaskStatus{ - Name: "test_" + name, - } - if err := testDB.Create(status).Error; err != nil { - t.Fatalf("Failed to create test status: %v", err) - } - return status -} // createTestTask creates a task with the given properties func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task { @@ -587,41 +575,18 @@ func TestScopeInProgressMatchesPredicate(t *testing.T) { } residenceID := createTestResidence(t) - - // For InProgress, we need to use the exact status name "In Progress" because - // the scope joins on task_taskstatus.name = 'In Progress' - // First, try to find existing "In Progress" status, or create one - var inProgressStatus models.TaskStatus - if err := testDB.Where("name = ?", "In Progress").First(&inProgressStatus).Error; err != nil { - // Create it if it doesn't exist - inProgressStatus = models.TaskStatus{Name: "In Progress"} - testDB.Create(&inProgressStatus) - } - - var pendingStatus models.TaskStatus - if err := testDB.Where("name = ?", "Pending").First(&pendingStatus).Error; err != nil { - pendingStatus = models.TaskStatus{Name: "Pending"} - testDB.Create(&pendingStatus) - } - defer cleanupTestData() // In progress task createTestTask(t, residenceID, &models.Task{ - Title: "in_progress", - StatusID: &inProgressStatus.ID, + Title: "in_progress", + InProgress: true, }) - // Not in progress: different status + // Not in progress: InProgress is false createTestTask(t, residenceID, &models.Task{ - Title: "pending", - StatusID: &pendingStatus.ID, - }) - - // Not in progress: no status - createTestTask(t, residenceID, &models.Task{ - Title: "no_status", - StatusID: nil, + Title: "not_in_progress", + InProgress: false, }) // Query using scope @@ -633,9 +598,9 @@ func TestScopeInProgressMatchesPredicate(t *testing.T) { t.Fatalf("Scope query failed: %v", err) } - // Query all tasks with status preloaded and filter with predicate + // Query all tasks and filter with predicate var allTasks []models.Task - testDB.Preload("Status").Where("residence_id = ?", residenceID).Find(&allTasks) + testDB.Where("residence_id = ?", residenceID).Find(&allTasks) var predicateResults []models.Task for _, task := range allTasks { diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index b73ea7f..7d21289 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -46,7 +46,6 @@ func SetupTestDB(t *testing.T) *gorm.DB { &models.Task{}, &models.TaskCategory{}, &models.TaskPriority{}, - &models.TaskStatus{}, &models.TaskFrequency{}, &models.TaskCompletion{}, &models.TaskCompletionImage{}, @@ -184,17 +183,6 @@ func CreateTestTaskPriority(t *testing.T, db *gorm.DB, name string, level int) * return priority } -// CreateTestTaskStatus creates a test task status -func CreateTestTaskStatus(t *testing.T, db *gorm.DB, name string) *models.TaskStatus { - status := &models.TaskStatus{ - Name: name, - DisplayOrder: 1, - } - err := db.Create(status).Error - require.NoError(t, err) - return status -} - // CreateTestTaskFrequency creates a test task frequency func CreateTestTaskFrequency(t *testing.T, db *gorm.DB, name string, days *int) *models.TaskFrequency { freq := &models.TaskFrequency{ @@ -256,17 +244,6 @@ func SeedLookupData(t *testing.T, db *gorm.DB) { db.Create(&p) } - // Task statuses - statuses := []models.TaskStatus{ - {Name: "Pending", DisplayOrder: 1}, - {Name: "In Progress", DisplayOrder: 2}, - {Name: "Completed", DisplayOrder: 3}, - {Name: "Cancelled", DisplayOrder: 4}, - } - for _, s := range statuses { - db.Create(&s) - } - // Task frequencies days7 := 7 days30 := 30 diff --git a/migrations/005_replace_status_with_in_progress.down.sql b/migrations/005_replace_status_with_in_progress.down.sql new file mode 100644 index 0000000..eccb631 --- /dev/null +++ b/migrations/005_replace_status_with_in_progress.down.sql @@ -0,0 +1,45 @@ +-- Rollback: Restore status_id foreign key from in_progress boolean + +-- Step 1: Recreate the task_taskstatus table +CREATE TABLE IF NOT EXISTS task_taskstatus ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ, + name VARCHAR(20) NOT NULL, + description TEXT, + color VARCHAR(7), + display_order INTEGER NOT NULL DEFAULT 0 +); + +-- Step 2: Seed the status lookup data +INSERT INTO task_taskstatus (name, description, color, display_order) VALUES + ('Pending', 'Task is waiting to be started', '#808080', 1), + ('In Progress', 'Task is currently being worked on', '#3498db', 2), + ('Completed', 'Task has been finished', '#27ae60', 3), + ('On Hold', 'Task is temporarily paused', '#f39c12', 4), + ('Cancelled', 'Task has been cancelled', '#e74c3c', 5) +ON CONFLICT DO NOTHING; + +-- Step 3: Add status_id column back +ALTER TABLE task_task ADD COLUMN IF NOT EXISTS status_id INTEGER; + +-- Step 4: Migrate data - set status_id based on in_progress flag +-- Set to "In Progress" status if in_progress is true, otherwise "Pending" +UPDATE task_task +SET status_id = ( + CASE + WHEN in_progress = true THEN (SELECT id FROM task_taskstatus WHERE name = 'In Progress' LIMIT 1) + ELSE (SELECT id FROM task_taskstatus WHERE name = 'Pending' LIMIT 1) + END +); + +-- Step 5: Add foreign key constraint +ALTER TABLE task_task ADD CONSTRAINT fk_task_task_status + FOREIGN KEY (status_id) REFERENCES task_taskstatus(id); + +-- Step 6: Drop the in_progress column +ALTER TABLE task_task DROP COLUMN IF EXISTS in_progress; + +-- Step 7: Drop the index +DROP INDEX IF EXISTS idx_task_task_in_progress; diff --git a/migrations/005_replace_status_with_in_progress.up.sql b/migrations/005_replace_status_with_in_progress.up.sql new file mode 100644 index 0000000..b16c333 --- /dev/null +++ b/migrations/005_replace_status_with_in_progress.up.sql @@ -0,0 +1,44 @@ +-- Migration: Replace status_id foreign key with in_progress boolean +-- This simplifies the task model since status was only used to determine if a task is "In Progress" + +-- Step 1: Add in_progress boolean column with default false +ALTER TABLE task_task ADD COLUMN IF NOT EXISTS in_progress BOOLEAN NOT NULL DEFAULT false; + +-- Step 2: Create index on in_progress for query performance +CREATE INDEX IF NOT EXISTS idx_task_task_in_progress ON task_task(in_progress); + +-- Step 3: Migrate existing data - set in_progress = true for tasks with "In Progress" status +UPDATE task_task +SET in_progress = true +WHERE status_id IN ( + SELECT id FROM task_taskstatus WHERE LOWER(name) = 'in progress' +); + +-- Step 4: Drop the foreign key constraint on status_id (if it exists) +-- PostgreSQL syntax - the constraint name might vary +DO $$ +BEGIN + -- Try to drop the constraint if it exists + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_task_task_status' + AND table_name = 'task_task' + ) THEN + ALTER TABLE task_task DROP CONSTRAINT fk_task_task_status; + END IF; + + -- Also try the gorm auto-generated constraint name + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'task_task_status_id_fkey' + AND table_name = 'task_task' + ) THEN + ALTER TABLE task_task DROP CONSTRAINT task_task_status_id_fkey; + END IF; +END $$; + +-- Step 5: Drop the status_id column +ALTER TABLE task_task DROP COLUMN IF EXISTS status_id; + +-- Step 6: Drop the task_taskstatus table +DROP TABLE IF EXISTS task_taskstatus;