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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
45
migrations/005_replace_status_with_in_progress.down.sql
Normal file
45
migrations/005_replace_status_with_in_progress.down.sql
Normal 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;
|
||||
44
migrations/005_replace_status_with_in_progress.up.sql
Normal file
44
migrations/005_replace_status_with_in_progress.up.sql
Normal 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;
|
||||
Reference in New Issue
Block a user