Replace status_id with in_progress boolean field

- Remove task_statuses lookup table and StatusID foreign key
- Add InProgress boolean field to Task model
- Add database migration (005_replace_status_with_in_progress)
- Update all handlers, services, and repositories
- Update admin frontend to display in_progress as checkbox/boolean
- Remove Task Statuses tab from admin lookups page
- Update tests to use InProgress instead of StatusID
- Task categorization now uses InProgress for kanban column assignment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-08 20:48:16 -06:00
parent cb250f108b
commit c5b0225422
43 changed files with 353 additions and 753 deletions

View File

@@ -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() {
<CardHeader>
<CardTitle>Reference Data</CardTitle>
<CardDescription>
Configure task categories, priorities, statuses, frequencies, residence types, and contractor specialties
Configure task categories, priorities, frequencies, residence types, and contractor specialties
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="categories" className="w-full">
<TabsList className="grid w-full grid-cols-6">
<TabsList className="grid w-full grid-cols-5">
{lookupTabs.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key} className="text-xs">
{tab.label}

View File

@@ -248,7 +248,7 @@ export function ResidenceDetailClient() {
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>In Progress</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Category</TableHead>
<TableHead>Due Date</TableHead>
@@ -271,7 +271,7 @@ export function ResidenceDetailClient() {
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{task.status_name || '-'}</Badge>
{task.in_progress ? '' : '—'}
</TableCell>
<TableCell>{task.priority_name || '-'}</TableCell>
<TableCell>{task.category_name || '-'}</TableCell>

View File

@@ -108,7 +108,7 @@ export default function SettingsPage() {
This will insert or update all lookup tables including:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Residence types</li>
<li>Task categories, priorities, statuses, frequencies</li>
<li>Task categories, priorities, frequencies</li>
<li>Contractor specialties</li>
<li>Subscription tiers and feature benefits</li>
<li><strong>Task templates (60+ predefined tasks)</strong></li>

View File

@@ -117,8 +117,8 @@ export function TaskDetailClient() {
<div>{task.priority_name || '-'}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<div>{task.status_name || '-'}</div>
<div className="text-sm font-medium text-muted-foreground">In Progress</div>
<div>{task.in_progress ? 'Yes' : 'No'}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Due Date</div>

View File

@@ -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() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status_id">Status</Label>
<Select
value={formData.status_id !== undefined ? formData.status_id.toString() : 'none'}
onValueChange={(value) => {
const newValue = value === 'none' ? undefined : Number(value);
// Only update if actually different (prevents spurious triggers)
if (newValue !== formData.status_id) {
updateField('status_id', newValue);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{statuses?.map((status: { id: number; name: string }) => (
<SelectItem key={status.id} value={status.id.toString()}>
{status.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center space-x-2 pt-8">
<input
type="checkbox"
id="in_progress"
checked={formData.in_progress ?? false}
onChange={(e) => updateField('in_progress', e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="in_progress">In Progress</Label>
</div>
<div className="space-y-2">
<Label htmlFor="frequency_id">Frequency</Label>

View File

@@ -58,9 +58,9 @@ const columns: ColumnDef<Task>[] = [
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',

View File

@@ -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'),

View File

@@ -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;

View File

@@ -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

View File

@@ -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"`

View File

@@ -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

View File

@@ -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 {

View File

@@ -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),
}

View File

@@ -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
}

View File

@@ -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")
{

View File

@@ -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

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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,
}

View File

@@ -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()

View File

@@ -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")

View File

@@ -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")
}

View File

@@ -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 ============

View File

@@ -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"`

View File

@@ -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{

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}
// =============================================================================

View File

@@ -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,
},
}

View File

@@ -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)
}
// =============================================================================

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;

View File

@@ -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;