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 = [
|
const lookupTabs = [
|
||||||
{ key: 'categories', label: 'Task Categories', api: lookupsApi.categories },
|
{ key: 'categories', label: 'Task Categories', api: lookupsApi.categories },
|
||||||
{ key: 'priorities', label: 'Task Priorities', api: lookupsApi.priorities },
|
{ 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: 'frequencies', label: 'Task Frequencies', api: lookupsApi.frequencies },
|
||||||
{ key: 'residenceTypes', label: 'Residence Types', api: lookupsApi.residenceTypes },
|
{ key: 'residenceTypes', label: 'Residence Types', api: lookupsApi.residenceTypes },
|
||||||
{ key: 'specialties', label: 'Contractor Specialties', api: lookupsApi.specialties },
|
{ key: 'specialties', label: 'Contractor Specialties', api: lookupsApi.specialties },
|
||||||
@@ -320,12 +319,12 @@ export default function LookupsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Reference Data</CardTitle>
|
<CardTitle>Reference Data</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure task categories, priorities, statuses, frequencies, residence types, and contractor specialties
|
Configure task categories, priorities, frequencies, residence types, and contractor specialties
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="categories" className="w-full">
|
<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) => (
|
{lookupTabs.map((tab) => (
|
||||||
<TabsTrigger key={tab.key} value={tab.key} className="text-xs">
|
<TabsTrigger key={tab.key} value={tab.key} className="text-xs">
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export function ResidenceDetailClient() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Title</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>In Progress</TableHead>
|
||||||
<TableHead>Priority</TableHead>
|
<TableHead>Priority</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Due Date</TableHead>
|
<TableHead>Due Date</TableHead>
|
||||||
@@ -271,7 +271,7 @@ export function ResidenceDetailClient() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{task.status_name || '-'}</Badge>
|
{task.in_progress ? '✓' : '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{task.priority_name || '-'}</TableCell>
|
<TableCell>{task.priority_name || '-'}</TableCell>
|
||||||
<TableCell>{task.category_name || '-'}</TableCell>
|
<TableCell>{task.category_name || '-'}</TableCell>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function SettingsPage() {
|
|||||||
This will insert or update all lookup tables including:
|
This will insert or update all lookup tables including:
|
||||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||||
<li>Residence types</li>
|
<li>Residence types</li>
|
||||||
<li>Task categories, priorities, statuses, frequencies</li>
|
<li>Task categories, priorities, frequencies</li>
|
||||||
<li>Contractor specialties</li>
|
<li>Contractor specialties</li>
|
||||||
<li>Subscription tiers and feature benefits</li>
|
<li>Subscription tiers and feature benefits</li>
|
||||||
<li><strong>Task templates (60+ predefined tasks)</strong></li>
|
<li><strong>Task templates (60+ predefined tasks)</strong></li>
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ export function TaskDetailClient() {
|
|||||||
<div>{task.priority_name || '-'}</div>
|
<div>{task.priority_name || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
<div className="text-sm font-medium text-muted-foreground">In Progress</div>
|
||||||
<div>{task.status_name || '-'}</div>
|
<div>{task.in_progress ? 'Yes' : 'No'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground">Due Date</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(),
|
queryFn: () => lookupsApi.priorities.list(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: statuses, isLoading: statusesLoading } = useQuery({
|
|
||||||
queryKey: ['lookups', 'statuses'],
|
|
||||||
queryFn: () => lookupsApi.statuses.list(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: frequencies, isLoading: frequenciesLoading } = useQuery({
|
const { data: frequencies, isLoading: frequenciesLoading } = useQuery({
|
||||||
queryKey: ['lookups', 'frequencies'],
|
queryKey: ['lookups', 'frequencies'],
|
||||||
queryFn: () => lookupsApi.frequencies.list(),
|
queryFn: () => lookupsApi.frequencies.list(),
|
||||||
@@ -85,7 +80,7 @@ export default function EditTaskPage() {
|
|||||||
const [formInitialized, setFormInitialized] = useState(false);
|
const [formInitialized, setFormInitialized] = useState(false);
|
||||||
|
|
||||||
// Wait for ALL data including lookups before initializing form
|
// Wait for ALL data including lookups before initializing form
|
||||||
const lookupsLoaded = !categoriesLoading && !prioritiesLoading && !statusesLoading && !frequenciesLoading;
|
const lookupsLoaded = !categoriesLoading && !prioritiesLoading && !frequenciesLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (task && lookupsLoaded && !formInitialized) {
|
if (task && lookupsLoaded && !formInitialized) {
|
||||||
@@ -97,7 +92,7 @@ export default function EditTaskPage() {
|
|||||||
description: task.description,
|
description: task.description,
|
||||||
category_id: task.category_id,
|
category_id: task.category_id,
|
||||||
priority_id: task.priority_id,
|
priority_id: task.priority_id,
|
||||||
status_id: task.status_id,
|
in_progress: task.in_progress,
|
||||||
frequency_id: task.frequency_id,
|
frequency_id: task.frequency_id,
|
||||||
due_date: task.due_date,
|
due_date: task.due_date,
|
||||||
next_due_date: task.next_due_date,
|
next_due_date: task.next_due_date,
|
||||||
@@ -323,30 +318,15 @@ export default function EditTaskPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center space-x-2 pt-8">
|
||||||
<Label htmlFor="status_id">Status</Label>
|
<input
|
||||||
<Select
|
type="checkbox"
|
||||||
value={formData.status_id !== undefined ? formData.status_id.toString() : 'none'}
|
id="in_progress"
|
||||||
onValueChange={(value) => {
|
checked={formData.in_progress ?? false}
|
||||||
const newValue = value === 'none' ? undefined : Number(value);
|
onChange={(e) => updateField('in_progress', e.target.checked)}
|
||||||
// Only update if actually different (prevents spurious triggers)
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
if (newValue !== formData.status_id) {
|
/>
|
||||||
updateField('status_id', newValue);
|
<Label htmlFor="in_progress">In Progress</Label>
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="frequency_id">Frequency</Label>
|
<Label htmlFor="frequency_id">Frequency</Label>
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ const columns: ColumnDef<Task>[] = [
|
|||||||
cell: ({ row }) => row.original.priority_name || '-',
|
cell: ({ row }) => row.original.priority_name || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status_name',
|
accessorKey: 'in_progress',
|
||||||
header: 'Status',
|
header: 'In Progress',
|
||||||
cell: ({ row }) => row.original.status_name || '-',
|
cell: ({ row }) => row.original.in_progress ? '✓' : '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'due_date',
|
accessorKey: 'due_date',
|
||||||
|
|||||||
@@ -517,7 +517,6 @@ const createLookupApi = (endpoint: string) => ({
|
|||||||
export const lookupsApi = {
|
export const lookupsApi = {
|
||||||
categories: createLookupApi('categories'),
|
categories: createLookupApi('categories'),
|
||||||
priorities: createLookupApi('priorities'),
|
priorities: createLookupApi('priorities'),
|
||||||
statuses: createLookupApi('statuses'),
|
|
||||||
frequencies: createLookupApi('frequencies'),
|
frequencies: createLookupApi('frequencies'),
|
||||||
residenceTypes: createLookupApi('residence-types'),
|
residenceTypes: createLookupApi('residence-types'),
|
||||||
specialties: createLookupApi('specialties'),
|
specialties: createLookupApi('specialties'),
|
||||||
|
|||||||
@@ -186,8 +186,7 @@ export interface Task {
|
|||||||
category_name?: string;
|
category_name?: string;
|
||||||
priority_id?: number;
|
priority_id?: number;
|
||||||
priority_name?: string;
|
priority_name?: string;
|
||||||
status_id?: number;
|
in_progress: boolean;
|
||||||
status_name?: string;
|
|
||||||
frequency_id?: number;
|
frequency_id?: number;
|
||||||
frequency_name?: string;
|
frequency_name?: string;
|
||||||
due_date?: string;
|
due_date?: string;
|
||||||
@@ -211,7 +210,7 @@ export interface TaskListParams extends ListParams {
|
|||||||
residence_id?: number;
|
residence_id?: number;
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
priority_id?: number;
|
priority_id?: number;
|
||||||
status_id?: number;
|
in_progress?: boolean;
|
||||||
is_cancelled?: boolean;
|
is_cancelled?: boolean;
|
||||||
is_archived?: boolean;
|
is_archived?: boolean;
|
||||||
}
|
}
|
||||||
@@ -223,7 +222,7 @@ export interface CreateTaskRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
priority_id?: number;
|
priority_id?: number;
|
||||||
status_id?: number;
|
in_progress?: boolean;
|
||||||
frequency_id?: number;
|
frequency_id?: number;
|
||||||
assigned_to_id?: number;
|
assigned_to_id?: number;
|
||||||
due_date?: string;
|
due_date?: string;
|
||||||
@@ -239,7 +238,7 @@ export interface UpdateTaskRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
priority_id?: number;
|
priority_id?: number;
|
||||||
status_id?: number;
|
in_progress?: boolean;
|
||||||
frequency_id?: number;
|
frequency_id?: number;
|
||||||
due_date?: string;
|
due_date?: string;
|
||||||
next_due_date?: string;
|
next_due_date?: string;
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ type TaskFilters struct {
|
|||||||
ResidenceID *uint `form:"residence_id"`
|
ResidenceID *uint `form:"residence_id"`
|
||||||
CategoryID *uint `form:"category_id"`
|
CategoryID *uint `form:"category_id"`
|
||||||
PriorityID *uint `form:"priority_id"`
|
PriorityID *uint `form:"priority_id"`
|
||||||
StatusID *uint `form:"status_id"`
|
InProgress *bool `form:"in_progress"`
|
||||||
IsCancelled *bool `form:"is_cancelled"`
|
IsCancelled *bool `form:"is_cancelled"`
|
||||||
IsArchived *bool `form:"is_archived"`
|
IsArchived *bool `form:"is_archived"`
|
||||||
}
|
}
|
||||||
@@ -132,8 +132,8 @@ type UpdateTaskRequest struct {
|
|||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
CategoryID *uint `json:"category_id"`
|
CategoryID *uint `json:"category_id"`
|
||||||
PriorityID *uint `json:"priority_id"`
|
PriorityID *uint `json:"priority_id"`
|
||||||
StatusID *uint `json:"status_id"`
|
|
||||||
FrequencyID *uint `json:"frequency_id"`
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
|
InProgress *bool `json:"in_progress"`
|
||||||
DueDate *string `json:"due_date"`
|
DueDate *string `json:"due_date"`
|
||||||
NextDueDate *string `json:"next_due_date"`
|
NextDueDate *string `json:"next_due_date"`
|
||||||
EstimatedCost *float64 `json:"estimated_cost"`
|
EstimatedCost *float64 `json:"estimated_cost"`
|
||||||
@@ -265,18 +265,18 @@ type CreateResidenceRequest struct {
|
|||||||
|
|
||||||
// CreateTaskRequest for creating a new task
|
// CreateTaskRequest for creating a new task
|
||||||
type CreateTaskRequest struct {
|
type CreateTaskRequest struct {
|
||||||
ResidenceID uint `json:"residence_id" binding:"required"`
|
ResidenceID uint `json:"residence_id" binding:"required"`
|
||||||
CreatedByID uint `json:"created_by_id" binding:"required"`
|
CreatedByID uint `json:"created_by_id" binding:"required"`
|
||||||
Title string `json:"title" binding:"required,max=200"`
|
Title string `json:"title" binding:"required,max=200"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CategoryID *uint `json:"category_id"`
|
CategoryID *uint `json:"category_id"`
|
||||||
PriorityID *uint `json:"priority_id"`
|
PriorityID *uint `json:"priority_id"`
|
||||||
StatusID *uint `json:"status_id"`
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
FrequencyID *uint `json:"frequency_id"`
|
InProgress bool `json:"in_progress"`
|
||||||
AssignedToID *uint `json:"assigned_to_id"`
|
AssignedToID *uint `json:"assigned_to_id"`
|
||||||
DueDate *string `json:"due_date"`
|
DueDate *string `json:"due_date"`
|
||||||
EstimatedCost *float64 `json:"estimated_cost"`
|
EstimatedCost *float64 `json:"estimated_cost"`
|
||||||
ContractorID *uint `json:"contractor_id"`
|
ContractorID *uint `json:"contractor_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateContractorRequest for creating a new contractor
|
// CreateContractorRequest for creating a new contractor
|
||||||
|
|||||||
@@ -126,10 +126,9 @@ type TaskResponse struct {
|
|||||||
CategoryName *string `json:"category_name,omitempty"`
|
CategoryName *string `json:"category_name,omitempty"`
|
||||||
PriorityID *uint `json:"priority_id,omitempty"`
|
PriorityID *uint `json:"priority_id,omitempty"`
|
||||||
PriorityName *string `json:"priority_name,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"`
|
FrequencyID *uint `json:"frequency_id,omitempty"`
|
||||||
FrequencyName *string `json:"frequency_name,omitempty"`
|
FrequencyName *string `json:"frequency_name,omitempty"`
|
||||||
|
InProgress bool `json:"in_progress"`
|
||||||
DueDate *string `json:"due_date,omitempty"`
|
DueDate *string `json:"due_date,omitempty"`
|
||||||
NextDueDate *string `json:"next_due_date,omitempty"`
|
NextDueDate *string `json:"next_due_date,omitempty"`
|
||||||
EstimatedCost *float64 `json:"estimated_cost,omitempty"`
|
EstimatedCost *float64 `json:"estimated_cost,omitempty"`
|
||||||
|
|||||||
@@ -62,13 +62,6 @@ type TaskStats struct {
|
|||||||
OnHold int64 `json:"on_hold"`
|
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
|
// ContractorStats holds contractor-related statistics
|
||||||
type ContractorStats struct {
|
type ContractorStats struct {
|
||||||
Total int64 `json:"total"`
|
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{}).Scopes(scopes.ScopeCancelled).Count(&stats.Tasks.Cancelled)
|
||||||
h.db.Model(&models.Task{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Tasks.New30d)
|
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)
|
// Task counts by in_progress flag
|
||||||
// Note: These status counts use DB status names, not kanban categorization
|
// Pending: active tasks that are not in progress and not completed
|
||||||
h.db.Model(&models.Task{}).
|
h.db.Model(&models.Task{}).
|
||||||
Scopes(scopes.ScopeActive).
|
Scopes(scopes.ScopeActive, scopes.ScopeNotInProgress, scopes.ScopeNotCompleted).
|
||||||
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").
|
|
||||||
Count(&stats.Tasks.Pending)
|
Count(&stats.Tasks.Pending)
|
||||||
|
|
||||||
|
// In Progress: active tasks with in_progress = true
|
||||||
h.db.Model(&models.Task{}).
|
h.db.Model(&models.Task{}).
|
||||||
Scopes(scopes.ScopeActive, scopes.ScopeInProgress).
|
Scopes(scopes.ScopeActive, scopes.ScopeInProgress).
|
||||||
Count(&stats.Tasks.InProgress)
|
Count(&stats.Tasks.InProgress)
|
||||||
@@ -141,11 +133,8 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
|
|||||||
Scopes(scopes.ScopeActive, scopes.ScopeCompleted).
|
Scopes(scopes.ScopeActive, scopes.ScopeCompleted).
|
||||||
Count(&stats.Tasks.Completed)
|
Count(&stats.Tasks.Completed)
|
||||||
|
|
||||||
h.db.Model(&models.Task{}).
|
// OnHold: no longer used with in_progress boolean, set to 0
|
||||||
Scopes(scopes.ScopeActive).
|
stats.Tasks.OnHold = 0
|
||||||
Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
|
||||||
Where("LOWER(task_taskstatus.name) = ?", "on hold").
|
|
||||||
Count(&stats.Tasks.OnHold)
|
|
||||||
|
|
||||||
// Overdue: uses consistent logic from internal/task/scopes.ScopeOverdue
|
// Overdue: uses consistent logic from internal/task/scopes.ScopeOverdue
|
||||||
// Effective date (COALESCE(next_due_date, due_date)) < now, active, not completed
|
// 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)
|
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
|
// refreshFrequenciesCache invalidates and refreshes the frequencies cache
|
||||||
func (h *AdminLookupHandler) refreshFrequenciesCache(ctx context.Context) {
|
func (h *AdminLookupHandler) refreshFrequenciesCache(ctx context.Context) {
|
||||||
cache := services.GetCache()
|
cache := services.GetCache()
|
||||||
@@ -471,149 +448,6 @@ func (h *AdminLookupHandler) DeletePriority(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"})
|
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 ==========
|
// ========== Task Frequencies ==========
|
||||||
|
|
||||||
type TaskFrequencyResponse struct {
|
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")
|
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
|
// Fetch and cache task frequencies
|
||||||
var frequencies []models.TaskFrequency
|
var frequencies []models.TaskFrequency
|
||||||
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
|
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_categories": categories,
|
||||||
"task_priorities": priorities,
|
"task_priorities": priorities,
|
||||||
"task_frequencies": frequencies,
|
"task_frequencies": frequencies,
|
||||||
"task_statuses": statuses,
|
|
||||||
"contractor_specialties": specialties,
|
"contractor_specialties": specialties,
|
||||||
"task_templates": buildGroupedTemplates(taskTemplates),
|
"task_templates": buildGroupedTemplates(taskTemplates),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ func (h *AdminTaskHandler) List(c *gin.Context) {
|
|||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
Preload("CreatedBy").
|
Preload("CreatedBy").
|
||||||
Preload("Category").
|
Preload("Category").
|
||||||
Preload("Priority").
|
Preload("Priority")
|
||||||
Preload("Status")
|
|
||||||
|
|
||||||
// Apply search
|
// Apply search
|
||||||
if filters.Search != "" {
|
if filters.Search != "" {
|
||||||
@@ -57,8 +56,8 @@ func (h *AdminTaskHandler) List(c *gin.Context) {
|
|||||||
if filters.PriorityID != nil {
|
if filters.PriorityID != nil {
|
||||||
query = query.Where("priority_id = ?", *filters.PriorityID)
|
query = query.Where("priority_id = ?", *filters.PriorityID)
|
||||||
}
|
}
|
||||||
if filters.StatusID != nil {
|
if filters.InProgress != nil {
|
||||||
query = query.Where("status_id = ?", *filters.StatusID)
|
query = query.Where("in_progress = ?", *filters.InProgress)
|
||||||
}
|
}
|
||||||
if filters.IsCancelled != nil {
|
if filters.IsCancelled != nil {
|
||||||
query = query.Where("is_cancelled = ?", *filters.IsCancelled)
|
query = query.Where("is_cancelled = ?", *filters.IsCancelled)
|
||||||
@@ -109,7 +108,6 @@ func (h *AdminTaskHandler) Get(c *gin.Context) {
|
|||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
Preload("Category").
|
||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("Status").
|
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
First(&task, id).Error; err != nil {
|
First(&task, id).Error; err != nil {
|
||||||
@@ -210,8 +208,8 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
|
|||||||
if req.PriorityID != nil {
|
if req.PriorityID != nil {
|
||||||
updates["priority_id"] = *req.PriorityID
|
updates["priority_id"] = *req.PriorityID
|
||||||
}
|
}
|
||||||
if req.StatusID != nil {
|
if req.InProgress != nil {
|
||||||
updates["status_id"] = *req.StatusID
|
updates["in_progress"] = *req.InProgress
|
||||||
}
|
}
|
||||||
if req.FrequencyID != nil {
|
if req.FrequencyID != nil {
|
||||||
updates["frequency_id"] = *req.FrequencyID
|
updates["frequency_id"] = *req.FrequencyID
|
||||||
@@ -254,7 +252,7 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reload with preloads for response
|
// 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))
|
c.JSON(http.StatusOK, h.toTaskResponse(&task))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +285,7 @@ func (h *AdminTaskHandler) Create(c *gin.Context) {
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: req.CategoryID,
|
||||||
PriorityID: req.PriorityID,
|
PriorityID: req.PriorityID,
|
||||||
StatusID: req.StatusID,
|
InProgress: req.InProgress,
|
||||||
FrequencyID: req.FrequencyID,
|
FrequencyID: req.FrequencyID,
|
||||||
AssignedToID: req.AssignedToID,
|
AssignedToID: req.AssignedToID,
|
||||||
ContractorID: req.ContractorID,
|
ContractorID: req.ContractorID,
|
||||||
@@ -311,7 +309,7 @@ func (h *AdminTaskHandler) Create(c *gin.Context) {
|
|||||||
return
|
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))
|
c.JSON(http.StatusCreated, h.toTaskResponse(&task))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +334,7 @@ func (h *AdminTaskHandler) Delete(c *gin.Context) {
|
|||||||
// Soft delete - archive and cancel
|
// Soft delete - archive and cancel
|
||||||
task.IsArchived = true
|
task.IsArchived = true
|
||||||
task.IsCancelled = 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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -373,7 +371,7 @@ func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse {
|
|||||||
Description: task.Description,
|
Description: task.Description,
|
||||||
CategoryID: task.CategoryID,
|
CategoryID: task.CategoryID,
|
||||||
PriorityID: task.PriorityID,
|
PriorityID: task.PriorityID,
|
||||||
StatusID: task.StatusID,
|
InProgress: task.InProgress,
|
||||||
FrequencyID: task.FrequencyID,
|
FrequencyID: task.FrequencyID,
|
||||||
ContractorID: task.ContractorID,
|
ContractorID: task.ContractorID,
|
||||||
ParentTaskID: task.ParentTaskID,
|
ParentTaskID: task.ParentTaskID,
|
||||||
@@ -401,9 +399,6 @@ func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse {
|
|||||||
if task.Priority != nil {
|
if task.Priority != nil {
|
||||||
response.PriorityName = &task.Priority.Name
|
response.PriorityName = &task.Priority.Name
|
||||||
}
|
}
|
||||||
if task.Status != nil {
|
|
||||||
response.StatusName = &task.Status.Name
|
|
||||||
}
|
|
||||||
if task.Frequency != nil {
|
if task.Frequency != nil {
|
||||||
response.FrequencyName = &task.Frequency.Name
|
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)
|
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
|
// Task Frequencies
|
||||||
frequencies := protected.Group("/lookups/frequencies")
|
frequencies := protected.Group("/lookups/frequencies")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ func Migrate() error {
|
|||||||
&models.TaskCategory{},
|
&models.TaskCategory{},
|
||||||
&models.TaskPriority{},
|
&models.TaskPriority{},
|
||||||
&models.TaskFrequency{},
|
&models.TaskFrequency{},
|
||||||
&models.TaskStatus{},
|
|
||||||
&models.ContractorSpecialty{},
|
&models.ContractorSpecialty{},
|
||||||
&models.TaskTemplate{}, // Task templates reference category and frequency
|
&models.TaskTemplate{}, // Task templates reference category and frequency
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ type CreateTaskRequest struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CategoryID *uint `json:"category_id"`
|
CategoryID *uint `json:"category_id"`
|
||||||
PriorityID *uint `json:"priority_id"`
|
PriorityID *uint `json:"priority_id"`
|
||||||
StatusID *uint `json:"status_id"`
|
|
||||||
FrequencyID *uint `json:"frequency_id"`
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
|
InProgress bool `json:"in_progress"`
|
||||||
AssignedToID *uint `json:"assigned_to_id"`
|
AssignedToID *uint `json:"assigned_to_id"`
|
||||||
DueDate *FlexibleDate `json:"due_date"`
|
DueDate *FlexibleDate `json:"due_date"`
|
||||||
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
|
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
|
||||||
@@ -73,8 +73,8 @@ type UpdateTaskRequest struct {
|
|||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
CategoryID *uint `json:"category_id"`
|
CategoryID *uint `json:"category_id"`
|
||||||
PriorityID *uint `json:"priority_id"`
|
PriorityID *uint `json:"priority_id"`
|
||||||
StatusID *uint `json:"status_id"`
|
|
||||||
FrequencyID *uint `json:"frequency_id"`
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
|
InProgress *bool `json:"in_progress"`
|
||||||
AssignedToID *uint `json:"assigned_to_id"`
|
AssignedToID *uint `json:"assigned_to_id"`
|
||||||
DueDate *FlexibleDate `json:"due_date"`
|
DueDate *FlexibleDate `json:"due_date"`
|
||||||
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
|
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
|
||||||
|
|||||||
@@ -29,15 +29,6 @@ type TaskPriorityResponse struct {
|
|||||||
DisplayOrder int `json:"display_order"`
|
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
|
// TaskFrequencyResponse represents a task frequency
|
||||||
type TaskFrequencyResponse struct {
|
type TaskFrequencyResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
@@ -91,10 +82,9 @@ type TaskResponse struct {
|
|||||||
Category *TaskCategoryResponse `json:"category,omitempty"`
|
Category *TaskCategoryResponse `json:"category,omitempty"`
|
||||||
PriorityID *uint `json:"priority_id"`
|
PriorityID *uint `json:"priority_id"`
|
||||||
Priority *TaskPriorityResponse `json:"priority,omitempty"`
|
Priority *TaskPriorityResponse `json:"priority,omitempty"`
|
||||||
StatusID *uint `json:"status_id"`
|
|
||||||
Status *TaskStatusResponse `json:"status,omitempty"`
|
|
||||||
FrequencyID *uint `json:"frequency_id"`
|
FrequencyID *uint `json:"frequency_id"`
|
||||||
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
||||||
|
InProgress bool `json:"in_progress"`
|
||||||
DueDate *time.Time `json:"due_date"`
|
DueDate *time.Time `json:"due_date"`
|
||||||
NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion
|
NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion
|
||||||
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
|
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
|
// NewTaskFrequencyResponse creates a TaskFrequencyResponse from a model
|
||||||
func NewTaskFrequencyResponse(f *models.TaskFrequency) *TaskFrequencyResponse {
|
func NewTaskFrequencyResponse(f *models.TaskFrequency) *TaskFrequencyResponse {
|
||||||
if f == nil {
|
if f == nil {
|
||||||
@@ -247,8 +223,8 @@ func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskRespons
|
|||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
CategoryID: t.CategoryID,
|
CategoryID: t.CategoryID,
|
||||||
PriorityID: t.PriorityID,
|
PriorityID: t.PriorityID,
|
||||||
StatusID: t.StatusID,
|
|
||||||
FrequencyID: t.FrequencyID,
|
FrequencyID: t.FrequencyID,
|
||||||
|
InProgress: t.InProgress,
|
||||||
AssignedToID: t.AssignedToID,
|
AssignedToID: t.AssignedToID,
|
||||||
DueDate: t.DueDate,
|
DueDate: t.DueDate,
|
||||||
NextDueDate: t.NextDueDate,
|
NextDueDate: t.NextDueDate,
|
||||||
@@ -276,9 +252,6 @@ func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskRespons
|
|||||||
if t.Priority != nil {
|
if t.Priority != nil {
|
||||||
resp.Priority = NewTaskPriorityResponse(t.Priority)
|
resp.Priority = NewTaskPriorityResponse(t.Priority)
|
||||||
}
|
}
|
||||||
if t.Status != nil {
|
|
||||||
resp.Status = NewTaskStatusResponse(t.Status)
|
|
||||||
}
|
|
||||||
if t.Frequency != nil {
|
if t.Frequency != nil {
|
||||||
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
|
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ import (
|
|||||||
|
|
||||||
// SeededDataResponse represents the unified seeded data response
|
// SeededDataResponse represents the unified seeded data response
|
||||||
type SeededDataResponse struct {
|
type SeededDataResponse struct {
|
||||||
ResidenceTypes interface{} `json:"residence_types"`
|
ResidenceTypes interface{} `json:"residence_types"`
|
||||||
TaskCategories interface{} `json:"task_categories"`
|
TaskCategories interface{} `json:"task_categories"`
|
||||||
TaskPriorities interface{} `json:"task_priorities"`
|
TaskPriorities interface{} `json:"task_priorities"`
|
||||||
TaskFrequencies interface{} `json:"task_frequencies"`
|
TaskFrequencies interface{} `json:"task_frequencies"`
|
||||||
TaskStatuses interface{} `json:"task_statuses"`
|
ContractorSpecialties interface{} `json:"contractor_specialties"`
|
||||||
ContractorSpecialties interface{} `json:"contractor_specialties"`
|
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"`
|
||||||
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaticDataHandler handles static/lookup data endpoints
|
// StaticDataHandler handles static/lookup data endpoints
|
||||||
@@ -113,12 +112,6 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
|
|||||||
return
|
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()
|
contractorSpecialties, err := h.contractorService.GetSpecialties()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_contractor_specialties")})
|
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,
|
TaskCategories: taskCategories,
|
||||||
TaskPriorities: taskPriorities,
|
TaskPriorities: taskPriorities,
|
||||||
TaskFrequencies: taskFrequencies,
|
TaskFrequencies: taskFrequencies,
|
||||||
TaskStatuses: taskStatuses,
|
|
||||||
ContractorSpecialties: contractorSpecialties,
|
ContractorSpecialties: contractorSpecialties,
|
||||||
TaskTemplates: taskTemplates,
|
TaskTemplates: taskTemplates,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -517,16 +517,6 @@ func (h *TaskHandler) GetPriorities(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, priorities)
|
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/
|
// GetFrequencies handles GET /api/tasks/frequencies/
|
||||||
func (h *TaskHandler) GetFrequencies(c *gin.Context) {
|
func (h *TaskHandler) GetFrequencies(c *gin.Context) {
|
||||||
frequencies, err := h.taskService.GetFrequencies()
|
frequencies, err := h.taskService.GetFrequencies()
|
||||||
|
|||||||
@@ -610,7 +610,6 @@ func TestTaskHandler_GetLookups(t *testing.T) {
|
|||||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
authGroup.GET("/categories/", handler.GetCategories)
|
authGroup.GET("/categories/", handler.GetCategories)
|
||||||
authGroup.GET("/priorities/", handler.GetPriorities)
|
authGroup.GET("/priorities/", handler.GetPriorities)
|
||||||
authGroup.GET("/statuses/", handler.GetStatuses)
|
|
||||||
authGroup.GET("/frequencies/", handler.GetFrequencies)
|
authGroup.GET("/frequencies/", handler.GetFrequencies)
|
||||||
|
|
||||||
t.Run("get categories", func(t *testing.T) {
|
t.Run("get categories", func(t *testing.T) {
|
||||||
@@ -642,18 +641,6 @@ func TestTaskHandler_GetLookups(t *testing.T) {
|
|||||||
assert.Contains(t, response[0], "level")
|
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) {
|
t.Run("get frequencies", func(t *testing.T) {
|
||||||
w := testutil.MakeRequest(router, "GET", "/api/tasks/frequencies/", nil, "test-token")
|
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{}
|
var residenceResp map[string]interface{}
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
err := json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
||||||
require.NoError(t, err)
|
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 ==========
|
// ========== User A shares residence C with User B ==========
|
||||||
// Generate share code
|
// Generate share code
|
||||||
@@ -191,7 +192,8 @@ func TestIntegration_ContractorAccessWithoutResidenceShare(t *testing.T) {
|
|||||||
|
|
||||||
var residenceResp map[string]interface{}
|
var residenceResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
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)
|
// User A creates a contractor tied to the residence (NOT shared with User B)
|
||||||
contractorBody := map[string]interface{}{
|
contractorBody := map[string]interface{}{
|
||||||
@@ -235,9 +237,10 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) {
|
|||||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, userAToken)
|
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, userAToken)
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
var residenceResp map[string]interface{}
|
var residenceResp2 map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
json.Unmarshal(w.Body.Bytes(), &residenceResp2)
|
||||||
residenceID := residenceResp["id"].(float64)
|
residenceData2 := residenceResp2["data"].(map[string]interface{})
|
||||||
|
residenceID := residenceData2["id"].(float64)
|
||||||
|
|
||||||
// Share with User B
|
// Share with User B
|
||||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/"+formatID(residenceID)+"/generate-share-code", nil, userAToken)
|
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)
|
w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBody, userAToken)
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
var contractorResp map[string]interface{}
|
var contractorResp3 map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &contractorResp)
|
json.Unmarshal(w.Body.Bytes(), &contractorResp3)
|
||||||
contractorID := contractorResp["id"].(float64)
|
contractorID3 := contractorResp3["id"].(float64)
|
||||||
|
|
||||||
// User B (with access) can update the contractor
|
// User B (with access) can update the contractor
|
||||||
// Note: Must include residence_id to keep it tied to the residence
|
// 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",
|
"name": "Updated by User B",
|
||||||
"residence_id": uint(residenceID),
|
"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")
|
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
|
// User C (without access) cannot update the contractor
|
||||||
@@ -277,15 +280,15 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) {
|
|||||||
"name": "Hacked by User C",
|
"name": "Hacked by User C",
|
||||||
"residence_id": uint(residenceID),
|
"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")
|
assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to update contractor")
|
||||||
|
|
||||||
// User C cannot delete the 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")
|
assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to delete contractor")
|
||||||
|
|
||||||
// User B (with access) can delete the 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")
|
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-categories", taskHandler.GetCategories)
|
||||||
api.GET("/task-priorities", taskHandler.GetPriorities)
|
api.GET("/task-priorities", taskHandler.GetPriorities)
|
||||||
api.GET("/task-statuses", taskHandler.GetStatuses)
|
|
||||||
api.GET("/task-frequencies", taskHandler.GetFrequencies)
|
api.GET("/task-frequencies", taskHandler.GetFrequencies)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +333,11 @@ func TestIntegration_ResidenceFlow(t *testing.T) {
|
|||||||
var createResp map[string]interface{}
|
var createResp map[string]interface{}
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &createResp)
|
err := json.Unmarshal(w.Body.Bytes(), &createResp)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
residenceID := createResp["id"].(float64)
|
createData := createResp["data"].(map[string]interface{})
|
||||||
|
residenceID := createData["id"].(float64)
|
||||||
assert.NotZero(t, residenceID)
|
assert.NotZero(t, residenceID)
|
||||||
assert.Equal(t, "My House", createResp["name"])
|
assert.Equal(t, "My House", createData["name"])
|
||||||
assert.True(t, createResp["is_primary"].(bool))
|
assert.True(t, createData["is_primary"].(bool))
|
||||||
|
|
||||||
// 2. Get the residence
|
// 2. Get the residence
|
||||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, token)
|
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{}
|
var updateResp map[string]interface{}
|
||||||
err = json.Unmarshal(w.Body.Bytes(), &updateResp)
|
err = json.Unmarshal(w.Body.Bytes(), &updateResp)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "My Updated House", updateResp["name"])
|
updateData := updateResp["data"].(map[string]interface{})
|
||||||
assert.Equal(t, "Dallas", updateResp["city"])
|
assert.Equal(t, "My Updated House", updateData["name"])
|
||||||
|
assert.Equal(t, "Dallas", updateData["city"])
|
||||||
|
|
||||||
// 5. Delete the residence (returns 200 with message, not 204)
|
// 5. Delete the residence (returns 200 with message, not 204)
|
||||||
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/residences/"+formatID(residenceID), nil, token)
|
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{}
|
var createResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &createResp)
|
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
|
// Other user cannot access initially
|
||||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, userToken)
|
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{}
|
var residenceResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
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
|
// 1. Create a task
|
||||||
taskBody := map[string]interface{}{
|
taskBody := map[string]interface{}{
|
||||||
@@ -461,9 +464,10 @@ func TestIntegration_TaskFlow(t *testing.T) {
|
|||||||
|
|
||||||
var taskResp map[string]interface{}
|
var taskResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &taskResp)
|
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.NotZero(t, taskID)
|
||||||
assert.Equal(t, "Fix leaky faucet", taskResp["title"])
|
assert.Equal(t, "Fix leaky faucet", taskData["title"])
|
||||||
|
|
||||||
// 2. Get the task
|
// 2. Get the task
|
||||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/"+formatID(taskID), nil, token)
|
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)
|
w = app.makeAuthenticatedRequest(t, "PUT", "/api/tasks/"+formatID(taskID), updateBody, token)
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
var updateResp map[string]interface{}
|
var taskUpdateResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &updateResp)
|
json.Unmarshal(w.Body.Bytes(), &taskUpdateResp)
|
||||||
assert.Equal(t, "Fix kitchen faucet", updateResp["title"])
|
taskUpdateData := taskUpdateResp["data"].(map[string]interface{})
|
||||||
|
assert.Equal(t, "Fix kitchen faucet", taskUpdateData["title"])
|
||||||
|
|
||||||
// 4. Mark as in progress
|
// 4. Mark as in progress
|
||||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/mark-in-progress", nil, token)
|
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{}
|
var progressResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &progressResp)
|
json.Unmarshal(w.Body.Bytes(), &progressResp)
|
||||||
task := progressResp["task"].(map[string]interface{})
|
progressData := progressResp["data"].(map[string]interface{})
|
||||||
status := task["status"].(map[string]interface{})
|
assert.True(t, progressData["in_progress"].(bool))
|
||||||
assert.Equal(t, "In Progress", status["name"])
|
|
||||||
|
|
||||||
// 5. Complete the task
|
// 5. Complete the task
|
||||||
completionBody := map[string]interface{}{
|
completionBody := map[string]interface{}{
|
||||||
@@ -501,9 +505,10 @@ func TestIntegration_TaskFlow(t *testing.T) {
|
|||||||
|
|
||||||
var completionResp map[string]interface{}
|
var completionResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &completionResp)
|
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.NotZero(t, completionID)
|
||||||
assert.Equal(t, "Fixed the faucet", completionResp["notes"])
|
assert.Equal(t, "Fixed the faucet", completionData["notes"])
|
||||||
|
|
||||||
// 6. List completions
|
// 6. List completions
|
||||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/completions?task_id="+formatID(taskID), nil, token)
|
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{}
|
var archiveResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &archiveResp)
|
json.Unmarshal(w.Body.Bytes(), &archiveResp)
|
||||||
archivedTask := archiveResp["task"].(map[string]interface{})
|
archivedData := archiveResp["data"].(map[string]interface{})
|
||||||
assert.True(t, archivedTask["is_archived"].(bool))
|
assert.True(t, archivedData["is_archived"].(bool))
|
||||||
|
|
||||||
// 8. Unarchive the task
|
// 8. Unarchive the task
|
||||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/unarchive", nil, token)
|
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{}
|
var cancelResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &cancelResp)
|
json.Unmarshal(w.Body.Bytes(), &cancelResp)
|
||||||
cancelledTask := cancelResp["task"].(map[string]interface{})
|
cancelledData := cancelResp["data"].(map[string]interface{})
|
||||||
assert.True(t, cancelledTask["is_cancelled"].(bool))
|
assert.True(t, cancelledData["is_cancelled"].(bool))
|
||||||
|
|
||||||
// 10. Delete the task (returns 200 with message, not 204)
|
// 10. Delete the task (returns 200 with message, not 204)
|
||||||
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/tasks/"+formatID(taskID), nil, token)
|
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{}
|
var residenceResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
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
|
// Create multiple tasks
|
||||||
for i := 1; i <= 3; i++ {
|
for i := 1; i <= 3; i++ {
|
||||||
@@ -592,7 +598,6 @@ func TestIntegration_LookupEndpoints(t *testing.T) {
|
|||||||
{"residence types", "/api/residence-types"},
|
{"residence types", "/api/residence-types"},
|
||||||
{"task categories", "/api/task-categories"},
|
{"task categories", "/api/task-categories"},
|
||||||
{"task priorities", "/api/task-priorities"},
|
{"task priorities", "/api/task-priorities"},
|
||||||
{"task statuses", "/api/task-statuses"},
|
|
||||||
{"task frequencies", "/api/task-frequencies"},
|
{"task frequencies", "/api/task-frequencies"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +638,8 @@ func TestIntegration_CrossUserAccessDenied(t *testing.T) {
|
|||||||
|
|
||||||
var residenceResp map[string]interface{}
|
var residenceResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
json.Unmarshal(w.Body.Bytes(), &residenceResp)
|
||||||
residenceID := residenceResp["id"].(float64)
|
residenceData := residenceResp["data"].(map[string]interface{})
|
||||||
|
residenceID := residenceData["id"].(float64)
|
||||||
|
|
||||||
// User1 creates a task
|
// User1 creates a task
|
||||||
taskBody := map[string]interface{}{
|
taskBody := map[string]interface{}{
|
||||||
@@ -645,7 +651,8 @@ func TestIntegration_CrossUserAccessDenied(t *testing.T) {
|
|||||||
|
|
||||||
var taskResp map[string]interface{}
|
var taskResp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &taskResp)
|
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
|
// User2 cannot access User1's residence
|
||||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, user2Token)
|
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{}
|
var resp map[string]interface{}
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
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{
|
expectedFields := []string{
|
||||||
"id", "owner_id", "name", "street_address", "city",
|
"id", "owner_id", "name", "street_address", "city",
|
||||||
"state_province", "postal_code", "country",
|
"state_province", "postal_code", "country",
|
||||||
@@ -701,13 +713,13 @@ func TestIntegration_ResponseStructure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, field := range expectedFields {
|
for _, field := range expectedFields {
|
||||||
_, exists := resp[field]
|
_, exists := data[field]
|
||||||
assert.True(t, exists, "Expected field %s to be present", field)
|
assert.True(t, exists, "Expected field %s to be present in data", field)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that nullable fields can be null
|
// Check that nullable fields can be null
|
||||||
assert.Nil(t, resp["bedrooms"])
|
assert.Nil(t, data["bedrooms"])
|
||||||
assert.Nil(t, resp["bathrooms"])
|
assert.Nil(t, data["bathrooms"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Helper Functions ============
|
// ============ Helper Functions ============
|
||||||
|
|||||||
@@ -35,20 +35,6 @@ func (TaskPriority) TableName() string {
|
|||||||
return "task_taskpriority"
|
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
|
// TaskFrequency represents the task_taskfrequency table
|
||||||
type TaskFrequency struct {
|
type TaskFrequency struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
@@ -79,11 +65,12 @@ type Task struct {
|
|||||||
Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
PriorityID *uint `gorm:"column:priority_id;index" json:"priority_id"`
|
PriorityID *uint `gorm:"column:priority_id;index" json:"priority_id"`
|
||||||
Priority *TaskPriority `gorm:"foreignKey:PriorityID" json:"priority,omitempty"`
|
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"`
|
FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"`
|
||||||
Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"`
|
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"`
|
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
|
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"`
|
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())
|
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) {
|
func TestTaskFrequency_TableName(t *testing.T) {
|
||||||
f := TaskFrequency{}
|
f := TaskFrequency{}
|
||||||
assert.Equal(t, "task_taskfrequency", f.TableName())
|
assert.Equal(t, "task_taskfrequency", f.TableName())
|
||||||
@@ -134,28 +129,6 @@ func TestTaskPriority_JSONSerialization(t *testing.T) {
|
|||||||
assert.Equal(t, "#e74c3c", result["color"])
|
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) {
|
func TestTaskFrequency_JSONSerialization(t *testing.T) {
|
||||||
days := 7
|
days := 7
|
||||||
freq := TaskFrequency{
|
freq := TaskFrequency{
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ func (r *TaskRepository) FindByID(id uint) (*models.Task, error) {
|
|||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
Preload("Category").
|
||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("Status").
|
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
@@ -49,7 +48,6 @@ func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error
|
|||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
Preload("Category").
|
||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("Status").
|
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
@@ -68,7 +66,6 @@ func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.
|
|||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
Preload("Category").
|
||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("Status").
|
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
@@ -87,7 +84,7 @@ func (r *TaskRepository) Create(task *models.Task) error {
|
|||||||
// Update updates a task
|
// Update updates a task
|
||||||
// Uses Omit to exclude associations that shouldn't be updated via Save
|
// Uses Omit to exclude associations that shouldn't be updated via Save
|
||||||
func (r *TaskRepository) Update(task *models.Task) error {
|
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
|
// Delete hard-deletes a task
|
||||||
@@ -98,10 +95,10 @@ func (r *TaskRepository) Delete(id uint) error {
|
|||||||
// === Task State Operations ===
|
// === Task State Operations ===
|
||||||
|
|
||||||
// MarkInProgress marks a task as in progress
|
// 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{}).
|
return r.db.Model(&models.Task{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Update("status_id", statusID).Error
|
Update("in_progress", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel cancels a task
|
// Cancel cancels a task
|
||||||
@@ -142,7 +139,6 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
|
|||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
Preload("Category").
|
||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("Status").
|
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
@@ -229,7 +225,6 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
|
|||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
Preload("Category").
|
||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("Status").
|
|
||||||
Preload("Frequency").
|
Preload("Frequency").
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
@@ -325,13 +320,6 @@ func (r *TaskRepository) GetAllPriorities() ([]models.TaskPriority, error) {
|
|||||||
return priorities, err
|
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
|
// GetAllFrequencies returns all task frequencies
|
||||||
func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) {
|
func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) {
|
||||||
var frequencies []models.TaskFrequency
|
var frequencies []models.TaskFrequency
|
||||||
@@ -339,16 +327,6 @@ func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) {
|
|||||||
return frequencies, err
|
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
|
// CountByResidence counts tasks in a residence
|
||||||
func (r *TaskRepository) CountByResidence(residenceID uint) (int64, error) {
|
func (r *TaskRepository) CountByResidence(residenceID uint) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
|
|||||||
@@ -277,15 +277,6 @@ func TestTaskRepository_GetAllPriorities(t *testing.T) {
|
|||||||
assert.Greater(t, len(priorities), 0)
|
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) {
|
func TestTaskRepository_GetAllFrequencies(t *testing.T) {
|
||||||
db := testutil.SetupTestDB(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")
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
|
||||||
// Get "In Progress" status
|
// Create a task with InProgress = true
|
||||||
var inProgressStatus models.TaskStatus
|
|
||||||
db.Where("name = ?", "In Progress").First(&inProgressStatus)
|
|
||||||
|
|
||||||
// Create a task with "In Progress" status
|
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
ResidenceID: residence.ID,
|
ResidenceID: residence.ID,
|
||||||
CreatedByID: user.ID,
|
CreatedByID: user.ID,
|
||||||
Title: "In Progress Task",
|
Title: "In Progress Task",
|
||||||
StatusID: &inProgressStatus.ID,
|
InProgress: true,
|
||||||
}
|
}
|
||||||
err := db.Create(task).Error
|
err := db.Create(task).Error
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -654,17 +641,13 @@ func TestKanbanBoard_CategoryPriority_CompletedTakesPrecedenceOverInProgress(t *
|
|||||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
|
||||||
// Get "In Progress" status
|
// Create a task that has InProgress = true AND a completion
|
||||||
var inProgressStatus models.TaskStatus
|
|
||||||
db.Where("name = ?", "In Progress").First(&inProgressStatus)
|
|
||||||
|
|
||||||
// Create a task that has "In Progress" status AND a completion
|
|
||||||
// Completed should take precedence
|
// Completed should take precedence
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
ResidenceID: residence.ID,
|
ResidenceID: residence.ID,
|
||||||
CreatedByID: user.ID,
|
CreatedByID: user.ID,
|
||||||
Title: "In Progress with Completion",
|
Title: "In Progress with Completion",
|
||||||
StatusID: &inProgressStatus.ID,
|
InProgress: true,
|
||||||
}
|
}
|
||||||
err := db.Create(task).Error
|
err := db.Create(task).Error
|
||||||
require.NoError(t, err)
|
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/categories/", taskHandler.GetCategories)
|
||||||
api.GET("/tasks/priorities/", taskHandler.GetPriorities)
|
api.GET("/tasks/priorities/", taskHandler.GetPriorities)
|
||||||
api.GET("/tasks/frequencies/", taskHandler.GetFrequencies)
|
api.GET("/tasks/frequencies/", taskHandler.GetFrequencies)
|
||||||
api.GET("/tasks/statuses/", taskHandler.GetStatuses)
|
|
||||||
api.GET("/contractors/specialties/", contractorHandler.GetSpecialties)
|
api.GET("/contractors/specialties/", contractorHandler.GetSpecialties)
|
||||||
|
|
||||||
// Task template routes (public, for app autocomplete)
|
// Task template routes (public, for app autocomplete)
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ const (
|
|||||||
LookupKeyPrefix = "lookup:"
|
LookupKeyPrefix = "lookup:"
|
||||||
LookupCategoriesKey = LookupKeyPrefix + "categories"
|
LookupCategoriesKey = LookupKeyPrefix + "categories"
|
||||||
LookupPrioritiesKey = LookupKeyPrefix + "priorities"
|
LookupPrioritiesKey = LookupKeyPrefix + "priorities"
|
||||||
LookupStatusesKey = LookupKeyPrefix + "statuses"
|
|
||||||
LookupFrequenciesKey = LookupKeyPrefix + "frequencies"
|
LookupFrequenciesKey = LookupKeyPrefix + "frequencies"
|
||||||
LookupResidenceTypesKey = LookupKeyPrefix + "residence_types"
|
LookupResidenceTypesKey = LookupKeyPrefix + "residence_types"
|
||||||
LookupSpecialtiesKey = LookupKeyPrefix + "specialties"
|
LookupSpecialtiesKey = LookupKeyPrefix + "specialties"
|
||||||
@@ -196,7 +195,6 @@ func (c *CacheService) InvalidateAllLookups(ctx context.Context) error {
|
|||||||
keys := []string{
|
keys := []string{
|
||||||
LookupCategoriesKey,
|
LookupCategoriesKey,
|
||||||
LookupPrioritiesKey,
|
LookupPrioritiesKey,
|
||||||
LookupStatusesKey,
|
|
||||||
LookupFrequenciesKey,
|
LookupFrequenciesKey,
|
||||||
LookupResidenceTypesKey,
|
LookupResidenceTypesKey,
|
||||||
LookupSpecialtiesKey,
|
LookupSpecialtiesKey,
|
||||||
@@ -239,21 +237,6 @@ func (c *CacheService) InvalidatePriorities(ctx context.Context) error {
|
|||||||
return c.Delete(ctx, LookupPrioritiesKey, StaticDataKey)
|
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
|
// CacheFrequencies caches task frequencies
|
||||||
func (c *CacheService) CacheFrequencies(ctx context.Context, data interface{}) error {
|
func (c *CacheService) CacheFrequencies(ctx context.Context, data interface{}) error {
|
||||||
return c.CacheLookupData(ctx, LookupFrequenciesKey, data)
|
return c.CacheLookupData(ctx, LookupFrequenciesKey, data)
|
||||||
|
|||||||
@@ -616,8 +616,8 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
|
|||||||
if task.Priority != nil {
|
if task.Priority != nil {
|
||||||
taskData.Priority = task.Priority.Name
|
taskData.Priority = task.Priority.Name
|
||||||
}
|
}
|
||||||
if task.Status != nil {
|
if task.InProgress {
|
||||||
taskData.Status = task.Status.Name
|
taskData.Status = "In Progress"
|
||||||
}
|
}
|
||||||
// Use effective date for report (NextDueDate ?? DueDate)
|
// Use effective date for report (NextDueDate ?? DueDate)
|
||||||
effectiveDate := predicates.EffectiveDate(&task)
|
effectiveDate := predicates.EffectiveDate(&task)
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ func TestResidenceService_CreateResidence(t *testing.T) {
|
|||||||
resp, err := service.CreateResidence(req, user.ID)
|
resp, err := service.CreateResidence(req, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, resp)
|
assert.NotNil(t, resp)
|
||||||
assert.Equal(t, "Test House", resp.Name)
|
assert.Equal(t, "Test House", resp.Data.Name)
|
||||||
assert.Equal(t, "123 Main St", resp.StreetAddress)
|
assert.Equal(t, "123 Main St", resp.Data.StreetAddress)
|
||||||
assert.Equal(t, "Austin", resp.City)
|
assert.Equal(t, "Austin", resp.Data.City)
|
||||||
assert.Equal(t, "TX", resp.StateProvince)
|
assert.Equal(t, "TX", resp.Data.StateProvince)
|
||||||
assert.Equal(t, "USA", resp.Country) // Default country
|
assert.Equal(t, "USA", resp.Data.Country) // Default country
|
||||||
assert.True(t, resp.IsPrimary) // Default is_primary
|
assert.True(t, resp.Data.IsPrimary) // Default is_primary
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResidenceService_CreateResidence_WithOptionalFields(t *testing.T) {
|
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)
|
resp, err := service.CreateResidence(req, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "Canada", resp.Country)
|
assert.Equal(t, "Canada", resp.Data.Country)
|
||||||
assert.Equal(t, 3, *resp.Bedrooms)
|
assert.Equal(t, 3, *resp.Data.Bedrooms)
|
||||||
assert.True(t, resp.Bathrooms.Equal(decimal.NewFromFloat(2.5)))
|
assert.True(t, resp.Data.Bathrooms.Equal(decimal.NewFromFloat(2.5)))
|
||||||
assert.Equal(t, 2000, *resp.SquareFootage)
|
assert.Equal(t, 2000, *resp.Data.SquareFootage)
|
||||||
// First residence defaults to primary regardless of request
|
// 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) {
|
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)
|
resp, err := service.UpdateResidence(residence.ID, user.ID, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "Updated Name", resp.Name)
|
assert.Equal(t, "Updated Name", resp.Data.Name)
|
||||||
assert.Equal(t, "Dallas", resp.City)
|
assert.Equal(t, "Dallas", resp.Data.City)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResidenceService_UpdateResidence_NotOwner(t *testing.T) {
|
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")
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should not be found
|
// Should not be found
|
||||||
@@ -221,7 +221,7 @@ func TestResidenceService_DeleteResidence_NotOwner(t *testing.T) {
|
|||||||
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
|
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
|
||||||
residenceRepo.AddUser(residence.ID, sharedUser.ID)
|
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)
|
assert.ErrorIs(t, err, ErrNotResidenceOwner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ KANBAN COLUMNS (in priority order):
|
|||||||
----------------------------------
|
----------------------------------
|
||||||
1. CANCELLED: Task.IsCancelled = true
|
1. CANCELLED: Task.IsCancelled = true
|
||||||
2. COMPLETED: NextDueDate = nil AND has completions (one-time task done)
|
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
|
4. OVERDUE: NextDueDate < now
|
||||||
5. DUE_SOON: NextDueDate < now + daysThreshold (default 30)
|
5. DUE_SOON: NextDueDate < now + daysThreshold (default 30)
|
||||||
6. UPCOMING: Everything else (NextDueDate >= threshold or no due date)
|
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)
|
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
|
// isTaskCompleted FUNCTION TESTS
|
||||||
@@ -157,7 +165,7 @@ func TestGetButtonTypesForTask_CompletedOneTimeTask(t *testing.T) {
|
|||||||
func TestGetButtonTypesForTask_InProgressTask(t *testing.T) {
|
func TestGetButtonTypesForTask_InProgressTask(t *testing.T) {
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
NextDueDate: ptr(daysFromNow(10)),
|
NextDueDate: ptr(daysFromNow(10)),
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons := GetButtonTypesForTask(task, 30)
|
buttons := GetButtonTypesForTask(task, 30)
|
||||||
@@ -237,7 +245,7 @@ func TestGetIOSCategoryForTask_CompletedTask(t *testing.T) {
|
|||||||
func TestGetIOSCategoryForTask_InProgressTask(t *testing.T) {
|
func TestGetIOSCategoryForTask_InProgressTask(t *testing.T) {
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
NextDueDate: ptr(daysFromNow(10)),
|
NextDueDate: ptr(daysFromNow(10)),
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
category := GetIOSCategoryForTask(task)
|
category := GetIOSCategoryForTask(task)
|
||||||
@@ -285,7 +293,7 @@ func TestDetermineKanbanColumn_CompletedOneTimeTask(t *testing.T) {
|
|||||||
func TestDetermineKanbanColumn_InProgressTask(t *testing.T) {
|
func TestDetermineKanbanColumn_InProgressTask(t *testing.T) {
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
NextDueDate: ptr(daysAgo(5)), // Even overdue
|
NextDueDate: ptr(daysAgo(5)), // Even overdue
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
column := responses.DetermineKanbanColumn(task, 30)
|
column := responses.DetermineKanbanColumn(task, 30)
|
||||||
@@ -902,7 +910,7 @@ func TestEdgeCase_CancelledAndOverdue(t *testing.T) {
|
|||||||
func TestEdgeCase_InProgressAndOverdue(t *testing.T) {
|
func TestEdgeCase_InProgressAndOverdue(t *testing.T) {
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
NextDueDate: ptr(daysAgo(5)),
|
NextDueDate: ptr(daysAgo(5)),
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
column := responses.DetermineKanbanColumn(task, 30)
|
column := responses.DetermineKanbanColumn(task, 30)
|
||||||
@@ -1011,7 +1019,7 @@ func TestButtonTypes_ConsistencyWithKanbanColumn(t *testing.T) {
|
|||||||
name: "In Progress task",
|
name: "In Progress task",
|
||||||
task: &models.Task{
|
task: &models.Task{
|
||||||
NextDueDate: ptr(daysFromNow(10)),
|
NextDueDate: ptr(daysFromNow(10)),
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
},
|
},
|
||||||
expectedColumn: "in_progress_tasks",
|
expectedColumn: "in_progress_tasks",
|
||||||
expectedButtons: []string{"edit", "complete", "cancel"},
|
expectedButtons: []string{"edit", "complete", "cancel"},
|
||||||
@@ -1062,7 +1070,7 @@ func TestPriorityOrder_CancelledBeatsEverything(t *testing.T) {
|
|||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
IsCancelled: true,
|
IsCancelled: true,
|
||||||
NextDueDate: ptr(daysAgo(10)),
|
NextDueDate: ptr(daysAgo(10)),
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
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
|
// One-time task with In Progress status but completed
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
NextDueDate: nil,
|
NextDueDate: nil,
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,7 +1094,7 @@ func TestPriorityOrder_InProgressBeatsDateBased(t *testing.T) {
|
|||||||
// Overdue task that's in progress
|
// Overdue task that's in progress
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
NextDueDate: ptr(daysAgo(10)),
|
NextDueDate: ptr(daysAgo(10)),
|
||||||
Status: &models.TaskStatus{Name: "In Progress"},
|
InProgress: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
column := responses.DetermineKanbanColumn(task, 30)
|
column := responses.DetermineKanbanColumn(task, 30)
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: req.CategoryID,
|
||||||
PriorityID: req.PriorityID,
|
PriorityID: req.PriorityID,
|
||||||
StatusID: req.StatusID,
|
|
||||||
FrequencyID: req.FrequencyID,
|
FrequencyID: req.FrequencyID,
|
||||||
|
InProgress: req.InProgress,
|
||||||
AssignedToID: req.AssignedToID,
|
AssignedToID: req.AssignedToID,
|
||||||
DueDate: dueDate,
|
DueDate: dueDate,
|
||||||
NextDueDate: dueDate, // Initialize next_due_date to due_date
|
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 {
|
if req.PriorityID != nil {
|
||||||
task.PriorityID = req.PriorityID
|
task.PriorityID = req.PriorityID
|
||||||
}
|
}
|
||||||
if req.StatusID != nil {
|
|
||||||
task.StatusID = req.StatusID
|
|
||||||
}
|
|
||||||
if req.FrequencyID != nil {
|
if req.FrequencyID != nil {
|
||||||
task.FrequencyID = req.FrequencyID
|
task.FrequencyID = req.FrequencyID
|
||||||
}
|
}
|
||||||
|
if req.InProgress != nil {
|
||||||
|
task.InProgress = *req.InProgress
|
||||||
|
}
|
||||||
if req.AssignedToID != nil {
|
if req.AssignedToID != nil {
|
||||||
task.AssignedToID = req.AssignedToID
|
task.AssignedToID = req.AssignedToID
|
||||||
}
|
}
|
||||||
@@ -324,13 +324,7 @@ func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSu
|
|||||||
return nil, ErrTaskAccessDenied
|
return nil, ErrTaskAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find "In Progress" status
|
if err := s.taskRepo.MarkInProgress(taskID); err != nil {
|
||||||
status, err := s.taskRepo.FindStatusByName("In Progress")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.taskRepo.MarkInProgress(taskID, status.ID); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,24 +528,22 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update next_due_date and status based on frequency
|
// Update next_due_date and in_progress based on frequency
|
||||||
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil and status to "Completed"
|
// - 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
|
// - 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 {
|
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
|
task.NextDueDate = nil
|
||||||
completedStatusID := uint(3)
|
task.InProgress = false
|
||||||
task.StatusID = &completedStatusID
|
|
||||||
} else {
|
} else {
|
||||||
// Recurring task - calculate next due date from completion date + frequency
|
// Recurring task - calculate next due date from completion date + frequency
|
||||||
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
|
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
|
||||||
task.NextDueDate = &nextDue
|
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
|
// instead of staying in "In Progress" column
|
||||||
pendingStatusID := uint(1)
|
task.InProgress = false
|
||||||
task.StatusID = &pendingStatusID
|
|
||||||
}
|
}
|
||||||
if err := s.taskRepo.Update(task); err != nil {
|
if err := s.taskRepo.Update(task); err != nil {
|
||||||
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
|
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
|
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 {
|
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
|
task.NextDueDate = nil
|
||||||
completedStatusID := uint(3)
|
task.InProgress = false
|
||||||
task.StatusID = &completedStatusID
|
|
||||||
} else {
|
} else {
|
||||||
// Recurring task - calculate next due date from completion date + frequency
|
// Recurring task - calculate next due date from completion date + frequency
|
||||||
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
|
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
|
||||||
task.NextDueDate = &nextDue
|
task.NextDueDate = &nextDue
|
||||||
|
|
||||||
// Reset status to "Pending" (ID=1)
|
// Reset in_progress to false
|
||||||
pendingStatusID := uint(1)
|
task.InProgress = false
|
||||||
task.StatusID = &pendingStatusID
|
|
||||||
}
|
}
|
||||||
if err := s.taskRepo.Update(task); err != nil {
|
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")
|
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
|
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
|
// GetFrequencies returns all task frequencies
|
||||||
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
|
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
|
||||||
frequencies, err := s.taskRepo.GetAllFrequencies()
|
frequencies, err := s.taskRepo.GetAllFrequencies()
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ func TestTaskService_CreateTask(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := service.CreateTask(req, user.ID)
|
resp, err := service.CreateTask(req, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, resp.ID)
|
assert.NotZero(t, resp.Data.ID)
|
||||||
assert.Equal(t, "Fix leaky faucet", resp.Title)
|
assert.Equal(t, "Fix leaky faucet", resp.Data.Title)
|
||||||
assert.Equal(t, "Kitchen faucet is dripping", resp.Description)
|
assert.Equal(t, "Kitchen faucet is dripping", resp.Data.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
|
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)
|
resp, err := service.CreateTask(req, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, resp.Category)
|
assert.NotNil(t, resp.Data.Category)
|
||||||
assert.NotNil(t, resp.Priority)
|
assert.NotNil(t, resp.Data.Priority)
|
||||||
assert.NotNil(t, resp.DueDate)
|
assert.NotNil(t, resp.Data.DueDate)
|
||||||
assert.NotNil(t, resp.EstimatedCost)
|
assert.NotNil(t, resp.Data.EstimatedCost)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_CreateTask_AccessDenied(t *testing.T) {
|
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)
|
resp, err := service.UpdateTask(task.ID, user.ID, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "Updated Title", resp.Title)
|
assert.Equal(t, "Updated Title", resp.Data.Title)
|
||||||
assert.Equal(t, "Updated description", resp.Description)
|
assert.Equal(t, "Updated description", resp.Data.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_DeleteTask(t *testing.T) {
|
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")
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = service.GetTask(task.ID, user.ID)
|
_, 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)
|
resp, err := service.CancelTask(task.ID, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, resp.IsCancelled)
|
assert.True(t, resp.Data.IsCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) {
|
func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) {
|
||||||
@@ -248,7 +248,7 @@ func TestTaskService_UncancelTask(t *testing.T) {
|
|||||||
service.CancelTask(task.ID, user.ID)
|
service.CancelTask(task.ID, user.ID)
|
||||||
resp, err := service.UncancelTask(task.ID, user.ID)
|
resp, err := service.UncancelTask(task.ID, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, resp.IsCancelled)
|
assert.False(t, resp.Data.IsCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_ArchiveTask(t *testing.T) {
|
func TestTaskService_ArchiveTask(t *testing.T) {
|
||||||
@@ -264,7 +264,7 @@ func TestTaskService_ArchiveTask(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := service.ArchiveTask(task.ID, user.ID)
|
resp, err := service.ArchiveTask(task.ID, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, resp.IsArchived)
|
assert.True(t, resp.Data.IsArchived)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_UnarchiveTask(t *testing.T) {
|
func TestTaskService_UnarchiveTask(t *testing.T) {
|
||||||
@@ -281,7 +281,7 @@ func TestTaskService_UnarchiveTask(t *testing.T) {
|
|||||||
service.ArchiveTask(task.ID, user.ID)
|
service.ArchiveTask(task.ID, user.ID)
|
||||||
resp, err := service.UnarchiveTask(task.ID, user.ID)
|
resp, err := service.UnarchiveTask(task.ID, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, resp.IsArchived)
|
assert.False(t, resp.Data.IsArchived)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_MarkInProgress(t *testing.T) {
|
func TestTaskService_MarkInProgress(t *testing.T) {
|
||||||
@@ -297,8 +297,7 @@ func TestTaskService_MarkInProgress(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := service.MarkInProgress(task.ID, user.ID)
|
resp, err := service.MarkInProgress(task.ID, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, resp.Status)
|
assert.True(t, resp.Data.InProgress)
|
||||||
assert.Equal(t, "In Progress", resp.Status.Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_CreateCompletion(t *testing.T) {
|
func TestTaskService_CreateCompletion(t *testing.T) {
|
||||||
@@ -319,12 +318,12 @@ func TestTaskService_CreateCompletion(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := service.CreateCompletion(req, user.ID)
|
resp, err := service.CreateCompletion(req, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, resp.ID)
|
assert.NotZero(t, resp.Data.ID)
|
||||||
assert.Equal(t, task.ID, resp.TaskID)
|
assert.Equal(t, task.ID, resp.Data.TaskID)
|
||||||
assert.Equal(t, "Completed successfully", resp.Notes)
|
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)
|
db := testutil.SetupTestDB(t)
|
||||||
testutil.SeedLookupData(t, db)
|
testutil.SeedLookupData(t, db)
|
||||||
taskRepo := repositories.NewTaskRepository(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")
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
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
|
var monthlyFrequency models.TaskFrequency
|
||||||
db.Where("name = ?", "Monthly").First(&monthlyFrequency)
|
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
|
dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days
|
||||||
task := &models.Task{
|
task := &models.Task{
|
||||||
ResidenceID: residence.ID,
|
ResidenceID: residence.ID,
|
||||||
CreatedByID: user.ID,
|
CreatedByID: user.ID,
|
||||||
Title: "Recurring Task",
|
Title: "Recurring Task",
|
||||||
StatusID: &inProgressStatus.ID,
|
InProgress: true,
|
||||||
FrequencyID: &monthlyFrequency.ID,
|
FrequencyID: &monthlyFrequency.ID,
|
||||||
DueDate: &dueDate,
|
DueDate: &dueDate,
|
||||||
NextDueDate: &dueDate,
|
NextDueDate: &dueDate,
|
||||||
@@ -365,24 +360,21 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *tes
|
|||||||
|
|
||||||
resp, err := service.CreateCompletion(req, user.ID)
|
resp, err := service.CreateCompletion(req, user.ID)
|
||||||
require.NoError(t, err)
|
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)
|
// Verify the task in the response has InProgress reset to false
|
||||||
require.NotNil(t, resp.Task, "Response should include the updated task")
|
require.NotNil(t, resp.Data.Task, "Response should include the updated task")
|
||||||
require.NotNil(t, resp.Task.StatusID, "Task should have a status ID")
|
assert.False(t, resp.Data.Task.InProgress, "Recurring task InProgress should be reset to false after completion")
|
||||||
assert.Equal(t, uint(1), *resp.Task.StatusID, "Recurring task status should be reset to Pending (ID=1) after completion")
|
|
||||||
|
|
||||||
// Verify NextDueDate was updated (should be ~30 days from now for monthly)
|
// 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
|
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
|
// Also verify by reloading from database directly
|
||||||
var reloadedTask models.Task
|
var reloadedTask models.Task
|
||||||
db.Preload("Status").First(&reloadedTask, task.ID)
|
db.First(&reloadedTask, task.ID)
|
||||||
require.NotNil(t, reloadedTask.StatusID)
|
assert.False(t, reloadedTask.InProgress, "Database should show InProgress=false")
|
||||||
assert.Equal(t, uint(1), *reloadedTask.StatusID, "Database should show Pending status")
|
|
||||||
assert.Equal(t, "Pending", reloadedTask.Status.Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskService_GetCompletion(t *testing.T) {
|
func TestTaskService_GetCompletion(t *testing.T) {
|
||||||
@@ -428,7 +420,7 @@ func TestTaskService_DeleteCompletion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(completion)
|
db.Create(completion)
|
||||||
|
|
||||||
err := service.DeleteCompletion(completion.ID, user.ID)
|
_, err := service.DeleteCompletion(completion.ID, user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = service.GetCompletion(completion.ID, user.ID)
|
_, 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) {
|
func TestTaskService_GetFrequencies(t *testing.T) {
|
||||||
db := testutil.SetupTestDB(t)
|
db := testutil.SetupTestDB(t)
|
||||||
testutil.SeedLookupData(t, db)
|
testutil.SeedLookupData(t, db)
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) {
|
|||||||
yesterday := now.AddDate(0, 0, -1)
|
yesterday := now.AddDate(0, 0, -1)
|
||||||
in5Days := now.AddDate(0, 0, 5)
|
in5Days := now.AddDate(0, 0, 5)
|
||||||
in60Days := now.AddDate(0, 0, 60)
|
in60Days := now.AddDate(0, 0, 60)
|
||||||
inProgressStatus := &models.TaskStatus{Name: "In Progress"}
|
|
||||||
daysThreshold := 30
|
daysThreshold := 30
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -32,7 +31,7 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) {
|
|||||||
task: &models.Task{
|
task: &models.Task{
|
||||||
IsCancelled: true,
|
IsCancelled: true,
|
||||||
NextDueDate: timePtr(yesterday), // Would be overdue
|
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
|
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil
|
||||||
},
|
},
|
||||||
expected: categorization.ColumnCancelled,
|
expected: categorization.ColumnCancelled,
|
||||||
@@ -68,7 +67,7 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) {
|
|||||||
IsCancelled: false,
|
IsCancelled: false,
|
||||||
IsArchived: false,
|
IsArchived: false,
|
||||||
NextDueDate: timePtr(yesterday), // Would be overdue
|
NextDueDate: timePtr(yesterday), // Would be overdue
|
||||||
Status: inProgressStatus,
|
InProgress: true,
|
||||||
Completions: []models.TaskCompletion{},
|
Completions: []models.TaskCompletion{},
|
||||||
},
|
},
|
||||||
expected: categorization.ColumnInProgress,
|
expected: categorization.ColumnInProgress,
|
||||||
@@ -151,13 +150,13 @@ func TestCategorizeTasksIntoColumns(t *testing.T) {
|
|||||||
daysThreshold := 30
|
daysThreshold := 30
|
||||||
|
|
||||||
tasks := []models.Task{
|
tasks := []models.Task{
|
||||||
{BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled
|
{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: 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: 3}, InProgress: true}, // In Progress
|
||||||
{BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue
|
{BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue
|
||||||
{BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon
|
{BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon
|
||||||
{BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming
|
{BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming
|
||||||
{BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date)
|
{BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
|
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
|
// TaskTestCase defines a test scenario with expected categorization
|
||||||
type TaskTestCase struct {
|
type TaskTestCase struct {
|
||||||
@@ -147,8 +138,6 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
|||||||
in60Days := now.AddDate(0, 0, 60)
|
in60Days := now.AddDate(0, 0, 60)
|
||||||
daysThreshold := 30
|
daysThreshold := 30
|
||||||
|
|
||||||
inProgressStatusID := getInProgressStatusID(t)
|
|
||||||
|
|
||||||
// Define all test cases with expected results for each layer
|
// Define all test cases with expected results for each layer
|
||||||
testCases := []TaskTestCase{
|
testCases := []TaskTestCase{
|
||||||
{
|
{
|
||||||
@@ -293,27 +282,23 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
|||||||
ExpectDueSoon: false,
|
ExpectDueSoon: false,
|
||||||
ExpectUpcoming: false,
|
ExpectUpcoming: false,
|
||||||
},
|
},
|
||||||
}
|
{
|
||||||
|
|
||||||
// Add in-progress test case only if status exists
|
|
||||||
if inProgressStatusID != nil {
|
|
||||||
testCases = append(testCases, TaskTestCase{
|
|
||||||
Name: "in_progress_overdue",
|
Name: "in_progress_overdue",
|
||||||
Task: &models.Task{
|
Task: &models.Task{
|
||||||
Title: "in_progress_overdue",
|
Title: "in_progress_overdue",
|
||||||
NextDueDate: timePtr(yesterday), // Would be overdue
|
NextDueDate: timePtr(yesterday), // Would be overdue
|
||||||
StatusID: inProgressStatusID,
|
InProgress: true,
|
||||||
IsCancelled: false,
|
IsCancelled: false,
|
||||||
IsArchived: false,
|
IsArchived: false,
|
||||||
},
|
},
|
||||||
ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority
|
ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority
|
||||||
ExpectCompleted: false,
|
ExpectCompleted: false,
|
||||||
ExpectActive: true,
|
ExpectActive: true,
|
||||||
ExpectOverdue: true, // Predicate says overdue (doesn't check status)
|
ExpectOverdue: true, // Predicate says overdue (doesn't check InProgress)
|
||||||
ExpectDueSoon: false,
|
ExpectDueSoon: false,
|
||||||
ExpectUpcoming: false,
|
ExpectUpcoming: false,
|
||||||
ExpectInProgress: true,
|
ExpectInProgress: true,
|
||||||
})
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create all tasks in database
|
// Create all tasks in database
|
||||||
@@ -330,7 +315,6 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
|||||||
var allTasks []models.Task
|
var allTasks []models.Task
|
||||||
err := testDB.
|
err := testDB.
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Status").
|
|
||||||
Where("residence_id = ?", residenceID).
|
Where("residence_id = ?", residenceID).
|
||||||
Find(&allTasks).Error
|
Find(&allTasks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -490,26 +474,24 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test ScopeInProgress (if status exists)
|
// Test ScopeInProgress
|
||||||
if inProgressStatusID != nil {
|
t.Run("ScopeInProgress", func(t *testing.T) {
|
||||||
t.Run("ScopeInProgress", func(t *testing.T) {
|
var scopeResults []models.Task
|
||||||
var scopeResults []models.Task
|
testDB.Model(&models.Task{}).
|
||||||
testDB.Model(&models.Task{}).
|
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
|
||||||
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
|
Find(&scopeResults)
|
||||||
Find(&scopeResults)
|
|
||||||
|
|
||||||
predicateCount := 0
|
predicateCount := 0
|
||||||
for _, task := range allTasks {
|
for _, task := range allTasks {
|
||||||
if predicates.IsInProgress(&task) {
|
if predicates.IsInProgress(&task) {
|
||||||
predicateCount++
|
predicateCount++
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(scopeResults) != predicateCount {
|
if len(scopeResults) != predicateCount {
|
||||||
t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount)
|
t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ==========
|
// ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ==========
|
||||||
@@ -527,7 +509,6 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
|||||||
t.Run("overdue_column", func(t *testing.T) {
|
t.Run("overdue_column", func(t *testing.T) {
|
||||||
var scopeResults []models.Task
|
var scopeResults []models.Task
|
||||||
testDB.Model(&models.Task{}).
|
testDB.Model(&models.Task{}).
|
||||||
Preload("Status").
|
|
||||||
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
|
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
|
||||||
Find(&scopeResults)
|
Find(&scopeResults)
|
||||||
|
|
||||||
@@ -612,7 +593,7 @@ func TestSameDayOverdueConsistency(t *testing.T) {
|
|||||||
|
|
||||||
// Reload with preloads
|
// Reload with preloads
|
||||||
var loadedTask models.Task
|
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
|
// All three layers should agree
|
||||||
predicateResult := predicates.IsOverdue(&loadedTask, now)
|
predicateResult := predicates.IsOverdue(&loadedTask, now)
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ func IsArchived(task *models.Task) bool {
|
|||||||
return task.IsArchived
|
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):
|
// SQL equivalent (in scopes.go ScopeInProgress):
|
||||||
//
|
//
|
||||||
// task_taskstatus.name = 'In Progress'
|
// in_progress = true
|
||||||
func IsInProgress(task *models.Task) bool {
|
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) {
|
func TestIsInProgress(t *testing.T) {
|
||||||
inProgressStatus := &models.TaskStatus{Name: "In Progress"}
|
|
||||||
pendingStatus := &models.TaskStatus{Name: "Pending"}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
task *models.Task
|
task *models.Task
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "in progress: status is In Progress",
|
name: "in progress: InProgress is true",
|
||||||
task: &models.Task{Status: inProgressStatus},
|
task: &models.Task{InProgress: true},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "not in progress: status is Pending",
|
name: "not in progress: InProgress is false",
|
||||||
task: &models.Task{Status: pendingStatus},
|
task: &models.Task{InProgress: false},
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not in progress: no status",
|
|
||||||
task: &models.Task{Status: nil},
|
|
||||||
expected: 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)
|
// 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 {
|
func ScopeInProgress(db *gorm.DB) *gorm.DB {
|
||||||
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
return db.Where("in_progress = ?", true)
|
||||||
Where("task_taskstatus.name = ?", "In Progress")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopeNotInProgress excludes tasks with status "In Progress".
|
// ScopeNotInProgress excludes tasks marked as in progress.
|
||||||
//
|
//
|
||||||
// Predicate equivalent: !IsInProgress(task)
|
// Predicate equivalent: !IsInProgress(task)
|
||||||
|
//
|
||||||
|
// SQL: in_progress = false
|
||||||
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
|
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
|
||||||
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
return db.Where("in_progress = ?", false)
|
||||||
Where("task_taskstatus.name != ? OR task_taskstatus.name IS NULL", "In Progress")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ func TestMain(m *testing.M) {
|
|||||||
err = testDB.AutoMigrate(
|
err = testDB.AutoMigrate(
|
||||||
&models.Task{},
|
&models.Task{},
|
||||||
&models.TaskCompletion{},
|
&models.TaskCompletion{},
|
||||||
&models.TaskStatus{},
|
|
||||||
&models.Residence{},
|
&models.Residence{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
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_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_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_%'")
|
testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'test_%'")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,16 +100,6 @@ func createTestResidence(t *testing.T) uint {
|
|||||||
return residence.ID
|
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
|
// createTestTask creates a task with the given properties
|
||||||
func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
|
func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
|
||||||
@@ -587,41 +575,18 @@ func TestScopeInProgressMatchesPredicate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
residenceID := createTestResidence(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()
|
defer cleanupTestData()
|
||||||
|
|
||||||
// In progress task
|
// In progress task
|
||||||
createTestTask(t, residenceID, &models.Task{
|
createTestTask(t, residenceID, &models.Task{
|
||||||
Title: "in_progress",
|
Title: "in_progress",
|
||||||
StatusID: &inProgressStatus.ID,
|
InProgress: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Not in progress: different status
|
// Not in progress: InProgress is false
|
||||||
createTestTask(t, residenceID, &models.Task{
|
createTestTask(t, residenceID, &models.Task{
|
||||||
Title: "pending",
|
Title: "not_in_progress",
|
||||||
StatusID: &pendingStatus.ID,
|
InProgress: false,
|
||||||
})
|
|
||||||
|
|
||||||
// Not in progress: no status
|
|
||||||
createTestTask(t, residenceID, &models.Task{
|
|
||||||
Title: "no_status",
|
|
||||||
StatusID: nil,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Query using scope
|
// Query using scope
|
||||||
@@ -633,9 +598,9 @@ func TestScopeInProgressMatchesPredicate(t *testing.T) {
|
|||||||
t.Fatalf("Scope query failed: %v", err)
|
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
|
var allTasks []models.Task
|
||||||
testDB.Preload("Status").Where("residence_id = ?", residenceID).Find(&allTasks)
|
testDB.Where("residence_id = ?", residenceID).Find(&allTasks)
|
||||||
|
|
||||||
var predicateResults []models.Task
|
var predicateResults []models.Task
|
||||||
for _, task := range allTasks {
|
for _, task := range allTasks {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ func SetupTestDB(t *testing.T) *gorm.DB {
|
|||||||
&models.Task{},
|
&models.Task{},
|
||||||
&models.TaskCategory{},
|
&models.TaskCategory{},
|
||||||
&models.TaskPriority{},
|
&models.TaskPriority{},
|
||||||
&models.TaskStatus{},
|
|
||||||
&models.TaskFrequency{},
|
&models.TaskFrequency{},
|
||||||
&models.TaskCompletion{},
|
&models.TaskCompletion{},
|
||||||
&models.TaskCompletionImage{},
|
&models.TaskCompletionImage{},
|
||||||
@@ -184,17 +183,6 @@ func CreateTestTaskPriority(t *testing.T, db *gorm.DB, name string, level int) *
|
|||||||
return priority
|
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
|
// CreateTestTaskFrequency creates a test task frequency
|
||||||
func CreateTestTaskFrequency(t *testing.T, db *gorm.DB, name string, days *int) *models.TaskFrequency {
|
func CreateTestTaskFrequency(t *testing.T, db *gorm.DB, name string, days *int) *models.TaskFrequency {
|
||||||
freq := &models.TaskFrequency{
|
freq := &models.TaskFrequency{
|
||||||
@@ -256,17 +244,6 @@ func SeedLookupData(t *testing.T, db *gorm.DB) {
|
|||||||
db.Create(&p)
|
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
|
// Task frequencies
|
||||||
days7 := 7
|
days7 := 7
|
||||||
days30 := 30
|
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