Add performance optimizations and database indexes
Database Indexes (migrations 006-009): - Add case-insensitive indexes for auth lookups (email, username) - Add composite indexes for task kanban queries - Add indexes for notification, document, and completion queries - Add unique index for active share codes - Remove redundant idx_share_code_active and idx_notification_user_sent Repository Optimizations: - Add FindResidenceIDsByUser() lightweight method (IDs only, no preloads) - Optimize GetResidenceUsers() with single UNION query (was 2 queries) - Optimize kanban completion preloads to minimal columns (id, task_id, completed_at) Service Optimizations: - Remove Category/Priority/Frequency preloads from task queries - Remove summary calculations from CRUD responses (client calculates) - Use lightweight FindResidenceIDsByUser() instead of full FindByUser() These changes reduce database load and response times for common operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
"github.com/treytartt/casera-api/internal/task/categorization"
|
"github.com/treytartt/casera-api/internal/task/categorization"
|
||||||
|
"github.com/treytartt/casera-api/internal/task/predicates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TaskCategoryResponse represents a task category
|
// TaskCategoryResponse represents a task category
|
||||||
@@ -234,7 +235,7 @@ func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskRespons
|
|||||||
IsCancelled: t.IsCancelled,
|
IsCancelled: t.IsCancelled,
|
||||||
IsArchived: t.IsArchived,
|
IsArchived: t.IsArchived,
|
||||||
ParentTaskID: t.ParentTaskID,
|
ParentTaskID: t.ParentTaskID,
|
||||||
CompletionCount: len(t.Completions),
|
CompletionCount: predicates.GetCompletionCount(t),
|
||||||
KanbanColumn: DetermineKanbanColumn(t, daysThreshold),
|
KanbanColumn: DetermineKanbanColumn(t, daysThreshold),
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
UpdatedAt: t.UpdatedAt,
|
UpdatedAt: t.UpdatedAt,
|
||||||
|
|||||||
@@ -100,8 +100,10 @@ func TestTaskHandler_CreateTask(t *testing.T) {
|
|||||||
|
|
||||||
taskData := response["data"].(map[string]interface{})
|
taskData := response["data"].(map[string]interface{})
|
||||||
assert.Equal(t, "Install new lights", taskData["title"])
|
assert.Equal(t, "Install new lights", taskData["title"])
|
||||||
assert.NotNil(t, taskData["category"])
|
// Note: Category and Priority are no longer preloaded for performance
|
||||||
assert.NotNil(t, taskData["priority"])
|
// Client resolves from cache using category_id and priority_id
|
||||||
|
assert.NotNil(t, taskData["category_id"], "category_id should be set")
|
||||||
|
assert.NotNil(t, taskData["priority_id"], "priority_id should be set")
|
||||||
assert.Equal(t, "150.5", taskData["estimated_cost"]) // Decimal serializes as string
|
assert.Equal(t, "150.5", taskData["estimated_cost"]) // Decimal serializes as string
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ type Task struct {
|
|||||||
|
|
||||||
// Completions
|
// Completions
|
||||||
Completions []TaskCompletion `gorm:"foreignKey:TaskID" json:"completions,omitempty"`
|
Completions []TaskCompletion `gorm:"foreignKey:TaskID" json:"completions,omitempty"`
|
||||||
|
|
||||||
|
// CompletionCount is a computed field populated via subquery for optimized queries.
|
||||||
|
// When populated (>= 0 and Completions slice is empty), predicates should use this
|
||||||
|
// instead of len(Completions) to avoid N+1 queries.
|
||||||
|
CompletionCount int `gorm:"-:all" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for GORM
|
// TableName returns the table name for GORM
|
||||||
@@ -116,7 +121,9 @@ func (t *Task) IsOverdue() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Completed check: NextDueDate == nil AND has completions
|
// Completed check: NextDueDate == nil AND has completions
|
||||||
if t.NextDueDate == nil && len(t.Completions) > 0 {
|
// Supports both preloaded Completions and computed CompletionCount
|
||||||
|
hasCompletions := len(t.Completions) > 0 || t.CompletionCount > 0
|
||||||
|
if t.NextDueDate == nil && hasCompletions {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Effective date: NextDueDate ?? DueDate
|
// Effective date: NextDueDate ?? DueDate
|
||||||
@@ -143,7 +150,9 @@ func (t *Task) IsDueSoon(days int) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Completed check: NextDueDate == nil AND has completions
|
// Completed check: NextDueDate == nil AND has completions
|
||||||
if t.NextDueDate == nil && len(t.Completions) > 0 {
|
// Supports both preloaded Completions and computed CompletionCount
|
||||||
|
hasCompletions := len(t.Completions) > 0 || t.CompletionCount > 0
|
||||||
|
if t.NextDueDate == nil && hasCompletions {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Effective date: NextDueDate ?? DueDate
|
// Effective date: NextDueDate ?? DueDate
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ func (r *ResidenceRepository) FindByUser(userID uint) ([]models.Residence, error
|
|||||||
return residences, nil
|
return residences, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindResidenceIDsByUser returns just the IDs of residences a user has access to.
|
||||||
|
// This is a lightweight alternative to FindByUser() when only IDs are needed.
|
||||||
|
// Avoids preloading Owner, Users, PropertyType relations.
|
||||||
|
func (r *ResidenceRepository) FindResidenceIDsByUser(userID uint) ([]uint, error) {
|
||||||
|
var ids []uint
|
||||||
|
err := r.db.Model(&models.Residence{}).
|
||||||
|
Where("is_active = ?", true).
|
||||||
|
Where("owner_id = ? OR id IN (?)",
|
||||||
|
userID,
|
||||||
|
r.db.Table("residence_residence_users").Select("residence_id").Where("user_id = ?", userID),
|
||||||
|
).
|
||||||
|
Pluck("id", &ids).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FindOwnedByUser finds all residences owned by a user
|
// FindOwnedByUser finds all residences owned by a user
|
||||||
func (r *ResidenceRepository) FindOwnedByUser(userID uint) ([]models.Residence, error) {
|
func (r *ResidenceRepository) FindOwnedByUser(userID uint) ([]models.Residence, error) {
|
||||||
var residences []models.Residence
|
var residences []models.Residence
|
||||||
@@ -118,42 +136,41 @@ func (r *ResidenceRepository) RemoveUser(residenceID, userID uint) error {
|
|||||||
).Error
|
).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetResidenceUsers returns all users with access to a residence
|
// GetResidenceUsers returns all users with access to a residence (owner + shared users).
|
||||||
|
// Optimized: Uses a single UNION query instead of preloading full residence with relations.
|
||||||
func (r *ResidenceRepository) GetResidenceUsers(residenceID uint) ([]models.User, error) {
|
func (r *ResidenceRepository) GetResidenceUsers(residenceID uint) ([]models.User, error) {
|
||||||
residence, err := r.FindByID(residenceID)
|
var users []models.User
|
||||||
|
// Single query to get both owner and shared users
|
||||||
|
err := r.db.Raw(`
|
||||||
|
SELECT DISTINCT u.* FROM auth_user u
|
||||||
|
WHERE u.id IN (
|
||||||
|
SELECT owner_id FROM residence_residence WHERE id = ? AND is_active = true
|
||||||
|
UNION
|
||||||
|
SELECT user_id FROM residence_residence_users WHERE residence_id = ?
|
||||||
|
)
|
||||||
|
`, residenceID, residenceID).Scan(&users).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
users := make([]models.User, 0, len(residence.Users)+1)
|
|
||||||
users = append(users, residence.Owner)
|
|
||||||
users = append(users, residence.Users...)
|
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasAccess checks if a user has access to a residence
|
// HasAccess checks if a user has access to a residence
|
||||||
func (r *ResidenceRepository) HasAccess(residenceID, userID uint) (bool, error) {
|
func (r *ResidenceRepository) HasAccess(residenceID, userID uint) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
|
// Single query using UNION to check owner OR member access
|
||||||
// Check if user is owner
|
err := r.db.Raw(`
|
||||||
err := r.db.Model(&models.Residence{}).
|
SELECT COUNT(*) FROM (
|
||||||
Where("id = ? AND owner_id = ? AND is_active = ?", residenceID, userID, true).
|
SELECT 1 FROM residence_residence
|
||||||
Count(&count).Error
|
WHERE id = ? AND owner_id = ? AND is_active = true
|
||||||
|
UNION
|
||||||
|
SELECT 1 FROM residence_residence_users
|
||||||
|
WHERE residence_id = ? AND user_id = ?
|
||||||
|
) access_check
|
||||||
|
`, residenceID, userID, residenceID, userID).Scan(&count).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is in shared users
|
|
||||||
err = r.db.Table("residence_residence_users").
|
|
||||||
Where("residence_id = ? AND user_id = ?", residenceID, userID).
|
|
||||||
Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,12 @@ func NewTaskRepository(db *gorm.DB) *TaskRepository {
|
|||||||
// === Task CRUD ===
|
// === Task CRUD ===
|
||||||
|
|
||||||
// FindByID finds a task by ID with preloaded relations
|
// FindByID finds a task by ID with preloaded relations
|
||||||
|
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
|
||||||
func (r *TaskRepository) FindByID(id uint) (*models.Task, error) {
|
func (r *TaskRepository) FindByID(id uint) (*models.Task, error) {
|
||||||
var task models.Task
|
var task models.Task
|
||||||
err := r.db.Preload("Residence").
|
err := r.db.Preload("Residence").
|
||||||
Preload("CreatedBy").
|
Preload("CreatedBy").
|
||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
|
||||||
Preload("Priority").
|
|
||||||
Preload("Frequency").
|
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
Preload("Completions.CompletedBy").
|
Preload("Completions.CompletedBy").
|
||||||
@@ -42,13 +40,11 @@ func (r *TaskRepository) FindByID(id uint) (*models.Task, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindByResidence finds all tasks for a residence
|
// FindByResidence finds all tasks for a residence
|
||||||
|
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
|
||||||
func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error) {
|
func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error) {
|
||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
|
||||||
Preload("Priority").
|
|
||||||
Preload("Frequency").
|
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
Preload("Completions.CompletedBy").
|
Preload("Completions.CompletedBy").
|
||||||
@@ -59,14 +55,12 @@ func (r *TaskRepository) FindByResidence(residenceID uint) ([]models.Task, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindByUser finds all tasks accessible to a user (across all their residences)
|
// FindByUser finds all tasks accessible to a user (across all their residences)
|
||||||
|
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
|
||||||
func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.Task, error) {
|
func (r *TaskRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.Task, error) {
|
||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
err := r.db.Preload("Residence").
|
err := r.db.Preload("Residence").
|
||||||
Preload("CreatedBy").
|
Preload("CreatedBy").
|
||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
|
||||||
Preload("Priority").
|
|
||||||
Preload("Frequency").
|
|
||||||
Preload("Completions").
|
Preload("Completions").
|
||||||
Preload("Completions.Images").
|
Preload("Completions.Images").
|
||||||
Preload("Completions.CompletedBy").
|
Preload("Completions.CompletedBy").
|
||||||
@@ -134,16 +128,18 @@ func (r *TaskRepository) Unarchive(id uint) error {
|
|||||||
// GetKanbanData retrieves tasks organized for kanban display.
|
// GetKanbanData retrieves tasks organized for kanban display.
|
||||||
// Uses the task.categorization package as the single source of truth for categorization logic.
|
// Uses the task.categorization package as the single source of truth for categorization logic.
|
||||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||||
|
//
|
||||||
|
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
|
||||||
|
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
|
||||||
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
|
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
|
||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
|
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
|
||||||
|
// Optimization: Preload only minimal Completions data (no Images, no CompletedBy)
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
Preload("Completions", func(db *gorm.DB) *gorm.DB {
|
||||||
Preload("Priority").
|
return db.Select("id", "task_id", "completed_at")
|
||||||
Preload("Frequency").
|
}).
|
||||||
Preload("Completions").
|
|
||||||
Preload("Completions.Images").
|
|
||||||
Preload("Completions.CompletedBy").
|
|
||||||
Where("residence_id = ? AND is_archived = ?", residenceID, false).
|
Where("residence_id = ? AND is_archived = ?", residenceID, false).
|
||||||
Scopes(task.ScopeKanbanOrder).
|
Scopes(task.ScopeKanbanOrder).
|
||||||
Find(&tasks).Error
|
Find(&tasks).Error
|
||||||
@@ -222,17 +218,19 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now
|
|||||||
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
|
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
|
||||||
// Uses the task.categorization package as the single source of truth for categorization logic.
|
// Uses the task.categorization package as the single source of truth for categorization logic.
|
||||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||||
|
//
|
||||||
|
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
|
||||||
|
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
|
||||||
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
|
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
|
||||||
var tasks []models.Task
|
var tasks []models.Task
|
||||||
|
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
|
||||||
|
// Optimization: Preload only minimal Completions data (no Images, no CompletedBy)
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("AssignedTo").
|
Preload("AssignedTo").
|
||||||
Preload("Category").
|
|
||||||
Preload("Priority").
|
|
||||||
Preload("Frequency").
|
|
||||||
Preload("Completions").
|
|
||||||
Preload("Completions.Images").
|
|
||||||
Preload("Completions.CompletedBy").
|
|
||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
|
Preload("Completions", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Select("id", "task_id", "completed_at")
|
||||||
|
}).
|
||||||
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
|
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
|
||||||
Scopes(task.ScopeKanbanOrder).
|
Scopes(task.ScopeKanbanOrder).
|
||||||
Find(&tasks).Error
|
Find(&tasks).Error
|
||||||
@@ -331,6 +329,16 @@ func (r *TaskRepository) GetAllFrequencies() ([]models.TaskFrequency, error) {
|
|||||||
return frequencies, err
|
return frequencies, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFrequencyByID retrieves a single frequency by ID
|
||||||
|
func (r *TaskRepository) GetFrequencyByID(id uint) (*models.TaskFrequency, error) {
|
||||||
|
var frequency models.TaskFrequency
|
||||||
|
err := r.db.First(&frequency, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &frequency, 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
|
||||||
@@ -421,66 +429,70 @@ type TaskStatistics struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTaskStatistics returns aggregated task statistics for multiple residences.
|
// GetTaskStatistics returns aggregated task statistics for multiple residences.
|
||||||
// Uses the task.scopes package for consistent filtering logic.
|
// Uses a single optimized query with CASE statements instead of 5 separate queries.
|
||||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||||
func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint, now time.Time) (*TaskStatistics, error) {
|
func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint, now time.Time) (*TaskStatistics, error) {
|
||||||
if len(residenceIDs) == 0 {
|
if len(residenceIDs) == 0 {
|
||||||
return &TaskStatistics{}, nil
|
return &TaskStatistics{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
|
nextWeek := now.AddDate(0, 0, 7)
|
||||||
|
nextMonth := now.AddDate(0, 0, 30)
|
||||||
|
|
||||||
// Count total active tasks (not cancelled, not archived)
|
// Single query with CASE statements to count all statistics at once
|
||||||
// Uses: task.ScopeActive, task.ScopeForResidences
|
// This replaces 5 separate COUNT queries with 1 query
|
||||||
|
type statsResult struct {
|
||||||
|
TotalTasks int64
|
||||||
|
TotalOverdue int64
|
||||||
|
TotalPending int64
|
||||||
|
TasksDueNextWeek int64
|
||||||
|
TasksDueNextMonth int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var result statsResult
|
||||||
|
|
||||||
|
// Build the optimized query
|
||||||
|
// Base conditions: active (not cancelled, not archived), in specified residences
|
||||||
|
// NotCompleted: NOT (next_due_date IS NULL AND has completions)
|
||||||
err := r.db.Model(&models.Task{}).
|
err := r.db.Model(&models.Task{}).
|
||||||
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive).
|
Select(`
|
||||||
Count(&totalTasks).Error
|
COUNT(*) as total_tasks,
|
||||||
if err != nil {
|
COUNT(CASE
|
||||||
return nil, err
|
WHEN COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
|
||||||
}
|
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
|
||||||
|
THEN 1
|
||||||
|
END) as total_overdue,
|
||||||
|
COUNT(CASE
|
||||||
|
WHEN NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
|
||||||
|
THEN 1
|
||||||
|
END) as total_pending,
|
||||||
|
COUNT(CASE
|
||||||
|
WHEN COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
|
||||||
|
AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
|
||||||
|
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
|
||||||
|
THEN 1
|
||||||
|
END) as tasks_due_next_week,
|
||||||
|
COUNT(CASE
|
||||||
|
WHEN COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
|
||||||
|
AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
|
||||||
|
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
|
||||||
|
THEN 1
|
||||||
|
END) as tasks_due_next_month
|
||||||
|
`, now, now, nextWeek, now, nextMonth).
|
||||||
|
Where("residence_id IN ?", residenceIDs).
|
||||||
|
Where("is_cancelled = ? AND is_archived = ?", false, false).
|
||||||
|
Scan(&result).Error
|
||||||
|
|
||||||
// Count overdue tasks using consistent scope
|
|
||||||
// Uses: task.ScopeOverdue (which includes ScopeActive and ScopeNotCompleted)
|
|
||||||
err = r.db.Model(&models.Task{}).
|
|
||||||
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeOverdue(now)).
|
|
||||||
Count(&totalOverdue).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count pending tasks (active, not completed)
|
|
||||||
// Uses: task.ScopeActive, task.ScopeNotCompleted
|
|
||||||
err = r.db.Model(&models.Task{}).
|
|
||||||
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeActive, task.ScopeNotCompleted).
|
|
||||||
Count(&totalPending).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count tasks due next week using consistent scope
|
|
||||||
// Uses: task.ScopeDueSoon with 7-day threshold
|
|
||||||
err = r.db.Model(&models.Task{}).
|
|
||||||
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 7)).
|
|
||||||
Count(&tasksDueNextWeek).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count tasks due next month using consistent scope
|
|
||||||
// Uses: task.ScopeDueSoon with 30-day threshold
|
|
||||||
err = r.db.Model(&models.Task{}).
|
|
||||||
Scopes(task.ScopeForResidences(residenceIDs), task.ScopeDueSoon(now, 30)).
|
|
||||||
Count(&tasksDueNextMonth).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &TaskStatistics{
|
return &TaskStatistics{
|
||||||
TotalTasks: int(totalTasks),
|
TotalTasks: int(result.TotalTasks),
|
||||||
TotalPending: int(totalPending),
|
TotalPending: int(result.TotalPending),
|
||||||
TotalOverdue: int(totalOverdue),
|
TotalOverdue: int(result.TotalOverdue),
|
||||||
TasksDueNextWeek: int(tasksDueNextWeek),
|
TasksDueNextWeek: int(result.TasksDueNextWeek),
|
||||||
TasksDueNextMonth: int(tasksDueNextMonth),
|
TasksDueNextMonth: int(result.TasksDueNextMonth),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ func TestKanbanBoard_CancelledTasksGoToCancelledColumn(t *testing.T) {
|
|||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Cancelled Task")
|
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Cancelled Task")
|
||||||
repo.Cancel(task.ID)
|
repo.Cancel(task.ID)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find cancelled column
|
// Find cancelled column
|
||||||
@@ -358,7 +358,7 @@ func TestKanbanBoard_CompletedTasksGoToCompletedColumn(t *testing.T) {
|
|||||||
err := repo.CreateCompletion(completion)
|
err := repo.CreateCompletion(completion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find completed column
|
// Find completed column
|
||||||
@@ -397,7 +397,7 @@ func TestKanbanBoard_InProgressTasksGoToInProgressColumn(t *testing.T) {
|
|||||||
err := db.Create(task).Error
|
err := db.Create(task).Error
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find in_progress column
|
// Find in_progress column
|
||||||
@@ -437,7 +437,7 @@ func TestKanbanBoard_OverdueTasksGoToOverdueColumn(t *testing.T) {
|
|||||||
err := db.Create(task).Error
|
err := db.Create(task).Error
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find overdue column
|
// Find overdue column
|
||||||
@@ -477,7 +477,7 @@ func TestKanbanBoard_DueSoonTasksGoToDueSoonColumn(t *testing.T) {
|
|||||||
err := db.Create(task).Error
|
err := db.Create(task).Error
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find due_soon column
|
// Find due_soon column
|
||||||
@@ -517,7 +517,7 @@ func TestKanbanBoard_UpcomingTasksGoToUpcomingColumn(t *testing.T) {
|
|||||||
err := db.Create(task).Error
|
err := db.Create(task).Error
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find upcoming column
|
// Find upcoming column
|
||||||
@@ -549,7 +549,7 @@ func TestKanbanBoard_TasksWithNoDueDateGoToUpcomingColumn(t *testing.T) {
|
|||||||
// Create a task with no due date
|
// Create a task with no due date
|
||||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "No Due Date Task")
|
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "No Due Date Task")
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find upcoming column
|
// Find upcoming column
|
||||||
@@ -579,7 +579,7 @@ func TestKanbanBoard_ArchivedTasksAreExcluded(t *testing.T) {
|
|||||||
archivedTask := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Archived Task")
|
archivedTask := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Archived Task")
|
||||||
repo.Archive(archivedTask.ID)
|
repo.Archive(archivedTask.ID)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Count total tasks across all columns
|
// Count total tasks across all columns
|
||||||
@@ -613,7 +613,7 @@ func TestKanbanBoard_CategoryPriority_CancelledTakesPrecedence(t *testing.T) {
|
|||||||
err := db.Create(task).Error
|
err := db.Create(task).Error
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find cancelled column
|
// Find cancelled column
|
||||||
@@ -661,7 +661,7 @@ func TestKanbanBoard_CategoryPriority_CompletedTakesPrecedenceOverInProgress(t *
|
|||||||
err = repo.CreateCompletion(completion)
|
err = repo.CreateCompletion(completion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Find columns
|
// Find columns
|
||||||
@@ -700,7 +700,7 @@ func TestKanbanBoard_DaysThresholdAffectsCategorization(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// With 30-day threshold, should be in "due_soon"
|
// With 30-day threshold, should be in "due_soon"
|
||||||
board30, err := repo.GetKanbanData(residence.ID, 30)
|
board30, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var dueSoon30, upcoming30 *models.KanbanColumn
|
var dueSoon30, upcoming30 *models.KanbanColumn
|
||||||
@@ -716,7 +716,7 @@ func TestKanbanBoard_DaysThresholdAffectsCategorization(t *testing.T) {
|
|||||||
assert.Equal(t, 0, upcoming30.Count, "With 30-day threshold, task should NOT be in upcoming")
|
assert.Equal(t, 0, upcoming30.Count, "With 30-day threshold, task should NOT be in upcoming")
|
||||||
|
|
||||||
// With 5-day threshold, should be in "upcoming"
|
// With 5-day threshold, should be in "upcoming"
|
||||||
board5, err := repo.GetKanbanData(residence.ID, 5)
|
board5, err := repo.GetKanbanData(residence.ID, 5, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var dueSoon5, upcoming5 *models.KanbanColumn
|
var dueSoon5, upcoming5 *models.KanbanColumn
|
||||||
@@ -740,7 +740,7 @@ func TestKanbanBoard_ColumnMetadata(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")
|
||||||
|
|
||||||
board, err := repo.GetKanbanData(residence.ID, 30)
|
board, err := repo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify all 6 columns exist with correct metadata
|
// Verify all 6 columns exist with correct metadata
|
||||||
@@ -793,7 +793,7 @@ func TestKanbanBoard_MultipleResidences(t *testing.T) {
|
|||||||
cancelledTask := testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Cancelled in House 1")
|
cancelledTask := testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Cancelled in House 1")
|
||||||
repo.Cancel(cancelledTask.ID)
|
repo.Cancel(cancelledTask.ID)
|
||||||
|
|
||||||
board, err := repo.GetKanbanDataForMultipleResidences([]uint{residence1.ID, residence2.ID}, 30)
|
board, err := repo.GetKanbanDataForMultipleResidences([]uint{residence1.ID, residence2.ID}, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Count total tasks
|
// Count total tasks
|
||||||
|
|||||||
@@ -70,16 +70,12 @@ func (s *ContractorService) hasContractorAccess(contractor *models.Contractor, u
|
|||||||
|
|
||||||
// ListContractors lists all contractors accessible to a user
|
// ListContractors lists all contractors accessible to a user
|
||||||
func (s *ContractorService) ListContractors(userID uint) ([]responses.ContractorResponse, error) {
|
func (s *ContractorService) ListContractors(userID uint) ([]responses.ContractorResponse, error) {
|
||||||
residences, err := s.residenceRepo.FindByUser(userID)
|
// Get residence IDs (lightweight - no preloads)
|
||||||
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
residenceIDs := make([]uint, len(residences))
|
|
||||||
for i, r := range residences {
|
|
||||||
residenceIDs[i] = r.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindByUser now handles both personal and residence contractors
|
// FindByUser now handles both personal and residence contractors
|
||||||
contractors, err := s.contractorRepo.FindByUser(userID, residenceIDs)
|
contractors, err := s.contractorRepo.FindByUser(userID, residenceIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -56,16 +56,12 @@ func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.Docum
|
|||||||
|
|
||||||
// ListDocuments lists all documents accessible to a user
|
// ListDocuments lists all documents accessible to a user
|
||||||
func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentResponse, error) {
|
func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentResponse, error) {
|
||||||
residences, err := s.residenceRepo.FindByUser(userID)
|
// Get residence IDs (lightweight - no preloads)
|
||||||
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
residenceIDs := make([]uint, len(residences))
|
|
||||||
for i, r := range residences {
|
|
||||||
residenceIDs[i] = r.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(residenceIDs) == 0 {
|
if len(residenceIDs) == 0 {
|
||||||
return []responses.DocumentResponse{}, nil
|
return []responses.DocumentResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -80,16 +76,12 @@ func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentRespon
|
|||||||
|
|
||||||
// ListWarranties lists all warranty documents
|
// ListWarranties lists all warranty documents
|
||||||
func (s *DocumentService) ListWarranties(userID uint) ([]responses.DocumentResponse, error) {
|
func (s *DocumentService) ListWarranties(userID uint) ([]responses.DocumentResponse, error) {
|
||||||
residences, err := s.residenceRepo.FindByUser(userID)
|
// Get residence IDs (lightweight - no preloads)
|
||||||
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
residenceIDs := make([]uint, len(residences))
|
|
||||||
for i, r := range residences {
|
|
||||||
residenceIDs[i] = r.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(residenceIDs) == 0 {
|
if len(residenceIDs) == 0 {
|
||||||
return []responses.DocumentResponse{}, nil
|
return []responses.DocumentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,23 +136,18 @@ func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*respons
|
|||||||
// This is a lightweight endpoint for refreshing summary counts without full residence data.
|
// This is a lightweight endpoint for refreshing summary counts without full residence data.
|
||||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||||
func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.TotalSummary, error) {
|
func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.TotalSummary, error) {
|
||||||
residences, err := s.residenceRepo.FindByUser(userID)
|
// Get residence IDs (lightweight - no preloads)
|
||||||
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := &responses.TotalSummary{
|
summary := &responses.TotalSummary{
|
||||||
TotalResidences: len(residences),
|
TotalResidences: len(residenceIDs),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get task statistics if task repository is available
|
// Get task statistics if task repository is available
|
||||||
if s.taskRepo != nil && len(residences) > 0 {
|
if s.taskRepo != nil && len(residenceIDs) > 0 {
|
||||||
// Collect residence IDs
|
|
||||||
residenceIDs := make([]uint, len(residences))
|
|
||||||
for i, r := range residences {
|
|
||||||
residenceIDs[i] = r.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get aggregated statistics using user's timezone-aware time
|
// Get aggregated statistics using user's timezone-aware time
|
||||||
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
|
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
|
||||||
if err == nil && stats != nil {
|
if err == nil && stats != nil {
|
||||||
@@ -167,15 +162,15 @@ func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.To
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSummaryForUser is a helper that returns summary for a user, or empty summary on error.
|
// getSummaryForUser returns an empty summary placeholder.
|
||||||
// Uses UTC time. For timezone-aware summary, use GetSummary directly.
|
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
|
||||||
func (s *ResidenceService) getSummaryForUser(userID uint) responses.TotalSummary {
|
// Clients should calculate summary from kanban data instead (which already includes all tasks).
|
||||||
summary, err := s.GetSummary(userID, time.Now().UTC())
|
// The summary field is kept in responses for backward compatibility but will always be empty.
|
||||||
if err != nil || summary == nil {
|
// For actual summary data, use GetSummary() directly or rely on my-residences/kanban endpoints.
|
||||||
|
func (s *ResidenceService) getSummaryForUser(_ uint) responses.TotalSummary {
|
||||||
|
// Return empty summary - clients should calculate from kanban data
|
||||||
return responses.TotalSummary{}
|
return responses.TotalSummary{}
|
||||||
}
|
}
|
||||||
return *summary
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateResidence creates a new residence and returns it with updated summary
|
// CreateResidence creates a new residence and returns it with updated summary
|
||||||
func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceWithSummaryResponse, error) {
|
func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceWithSummaryResponse, error) {
|
||||||
|
|||||||
@@ -533,13 +533,14 @@ func TestRecurringTask_Lifecycle_FirstCompletion(t *testing.T) {
|
|||||||
err = taskRepo.CreateCompletion(completion)
|
err = taskRepo.CreateCompletion(completion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Reload to get frequency
|
// Reload task
|
||||||
task, err = taskRepo.FindByID(task.ID)
|
task, err = taskRepo.FindByID(task.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Simulate the next_due_date update (from task_service.CreateCompletion)
|
// Simulate the next_due_date update (from task_service.CreateCompletion)
|
||||||
// For recurring task: NextDueDate = CompletedAt + FrequencyDays
|
// For recurring task: NextDueDate = CompletedAt + FrequencyDays
|
||||||
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
|
// Note: Frequency is no longer preloaded for performance, use the weeklyFreq we already have
|
||||||
|
nextDue := completedAt.AddDate(0, 0, *weeklyFreq.Days)
|
||||||
task.NextDueDate = &nextDue
|
task.NextDueDate = &nextDue
|
||||||
err = taskRepo.Update(task)
|
err = taskRepo.Update(task)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -603,7 +604,8 @@ func TestRecurringTask_Lifecycle_MultipleCompletions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
task, _ = taskRepo.FindByID(task.ID)
|
task, _ = taskRepo.FindByID(task.ID)
|
||||||
nextDue1 := completedAt1.AddDate(0, 0, *task.Frequency.Days)
|
// Note: Frequency is no longer preloaded for performance, use weeklyFreq we already have
|
||||||
|
nextDue1 := completedAt1.AddDate(0, 0, *weeklyFreq.Days)
|
||||||
task.NextDueDate = &nextDue1
|
task.NextDueDate = &nextDue1
|
||||||
taskRepo.Update(task)
|
taskRepo.Update(task)
|
||||||
|
|
||||||
@@ -632,7 +634,8 @@ func TestRecurringTask_Lifecycle_MultipleCompletions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
task, _ = taskRepo.FindByID(task.ID)
|
task, _ = taskRepo.FindByID(task.ID)
|
||||||
nextDue2 := completedAt2.AddDate(0, 0, *task.Frequency.Days)
|
// Note: Frequency is no longer preloaded for performance, use weeklyFreq we already have
|
||||||
|
nextDue2 := completedAt2.AddDate(0, 0, *weeklyFreq.Days)
|
||||||
task.NextDueDate = &nextDue2
|
task.NextDueDate = &nextDue2
|
||||||
taskRepo.Update(task)
|
taskRepo.Update(task)
|
||||||
|
|
||||||
@@ -725,7 +728,7 @@ func TestKanbanBoard_OneTimeTaskCompletion_MovesToCompleted(t *testing.T) {
|
|||||||
db.Create(task)
|
db.Create(task)
|
||||||
|
|
||||||
// Before completion - should be in due_soon
|
// Before completion - should be in due_soon
|
||||||
board, err := taskRepo.GetKanbanData(residence.ID, 30)
|
board, err := taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
dueSoonCount := 0
|
dueSoonCount := 0
|
||||||
@@ -751,7 +754,7 @@ func TestKanbanBoard_OneTimeTaskCompletion_MovesToCompleted(t *testing.T) {
|
|||||||
db.Save(task)
|
db.Save(task)
|
||||||
|
|
||||||
// After completion - should be in completed
|
// After completion - should be in completed
|
||||||
board, err = taskRepo.GetKanbanData(residence.ID, 30)
|
board, err = taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, col := range board.Columns {
|
for _, col := range board.Columns {
|
||||||
@@ -790,7 +793,7 @@ func TestKanbanBoard_RecurringTaskCompletion_StaysActionable(t *testing.T) {
|
|||||||
db.Create(task)
|
db.Create(task)
|
||||||
|
|
||||||
// Before completion - should be overdue
|
// Before completion - should be overdue
|
||||||
board, err := taskRepo.GetKanbanData(residence.ID, 30)
|
board, err := taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var overdueCount, completedCount, dueSoonCount int
|
var overdueCount, completedCount, dueSoonCount int
|
||||||
@@ -821,7 +824,7 @@ func TestKanbanBoard_RecurringTaskCompletion_StaysActionable(t *testing.T) {
|
|||||||
db.Save(task)
|
db.Save(task)
|
||||||
|
|
||||||
// After completion - should be in due_soon, NOT completed
|
// After completion - should be in due_soon, NOT completed
|
||||||
board, err = taskRepo.GetKanbanData(residence.ID, 30)
|
board, err = taskRepo.GetKanbanData(residence.ID, 30, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, col := range board.Columns {
|
for _, col := range board.Columns {
|
||||||
|
|||||||
@@ -55,18 +55,14 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) {
|
|||||||
s.residenceService = rs
|
s.residenceService = rs
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSummaryForUser gets the total summary for a user (helper for CRUD responses).
|
// getSummaryForUser returns an empty summary placeholder.
|
||||||
// Uses UTC time. For timezone-aware summary, call residence service directly.
|
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
|
||||||
func (s *TaskService) getSummaryForUser(userID uint) responses.TotalSummary {
|
// Clients should calculate summary from kanban data instead (which already includes all tasks).
|
||||||
if s.residenceService == nil {
|
// The summary field is kept in responses for backward compatibility but will always be empty.
|
||||||
|
func (s *TaskService) getSummaryForUser(_ uint) responses.TotalSummary {
|
||||||
|
// Return empty summary - clients should calculate from kanban data
|
||||||
return responses.TotalSummary{}
|
return responses.TotalSummary{}
|
||||||
}
|
}
|
||||||
summary, err := s.residenceService.GetSummary(userID, time.Now().UTC())
|
|
||||||
if err != nil || summary == nil {
|
|
||||||
return responses.TotalSummary{}
|
|
||||||
}
|
|
||||||
return *summary
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Task CRUD ===
|
// === Task CRUD ===
|
||||||
|
|
||||||
@@ -96,17 +92,12 @@ func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, err
|
|||||||
// ListTasks lists all tasks accessible to a user as a kanban board.
|
// ListTasks lists all tasks accessible to a user as a kanban board.
|
||||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||||
func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBoardResponse, error) {
|
func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBoardResponse, error) {
|
||||||
// Get all residence IDs accessible to user
|
// Get all residence IDs accessible to user (lightweight - no preloads)
|
||||||
residences, err := s.residenceRepo.FindByUser(userID)
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
residenceIDs := make([]uint, len(residences))
|
|
||||||
for i, r := range residences {
|
|
||||||
residenceIDs[i] = r.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(residenceIDs) == 0 {
|
if len(residenceIDs) == 0 {
|
||||||
// Return empty kanban board
|
// Return empty kanban board
|
||||||
return &responses.KanbanBoardResponse{
|
return &responses.KanbanBoardResponse{
|
||||||
@@ -541,13 +532,20 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
|||||||
// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
|
// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
|
||||||
// - 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 in_progress to false so task shows in correct kanban column
|
// and reset in_progress to false so task shows in correct kanban column
|
||||||
|
//
|
||||||
|
// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
|
||||||
var intervalDays *int
|
var intervalDays *int
|
||||||
if task.Frequency != nil && task.Frequency.Name == "Custom" {
|
if task.FrequencyID != nil {
|
||||||
|
frequency, err := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
||||||
|
if err == nil && frequency != nil {
|
||||||
|
if frequency.Name == "Custom" {
|
||||||
// Custom frequency - use task's custom_interval_days
|
// Custom frequency - use task's custom_interval_days
|
||||||
intervalDays = task.CustomIntervalDays
|
intervalDays = task.CustomIntervalDays
|
||||||
} else if task.Frequency != nil {
|
} else {
|
||||||
// Standard frequency - use frequency's days
|
// Standard frequency - use frequency's days
|
||||||
intervalDays = task.Frequency.Days
|
intervalDays = frequency.Days
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if intervalDays == nil || *intervalDays == 0 {
|
if intervalDays == nil || *intervalDays == 0 {
|
||||||
@@ -645,28 +643,33 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
|
|||||||
|
|
||||||
// Update next_due_date and in_progress based on frequency
|
// Update next_due_date and in_progress based on frequency
|
||||||
// Determine interval days: Custom frequency uses task.CustomIntervalDays, otherwise use frequency.Days
|
// Determine interval days: Custom frequency uses task.CustomIntervalDays, otherwise use frequency.Days
|
||||||
|
// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
|
||||||
var quickIntervalDays *int
|
var quickIntervalDays *int
|
||||||
if task.Frequency != nil && task.Frequency.Name == "Custom" {
|
var frequencyName = "unknown"
|
||||||
|
if task.FrequencyID != nil {
|
||||||
|
frequency, err := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
|
||||||
|
if err == nil && frequency != nil {
|
||||||
|
frequencyName = frequency.Name
|
||||||
|
if frequency.Name == "Custom" {
|
||||||
quickIntervalDays = task.CustomIntervalDays
|
quickIntervalDays = task.CustomIntervalDays
|
||||||
} else if task.Frequency != nil {
|
} else {
|
||||||
quickIntervalDays = task.Frequency.Days
|
quickIntervalDays = frequency.Days
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if quickIntervalDays == nil || *quickIntervalDays == 0 {
|
if quickIntervalDays == nil || *quickIntervalDays == 0 {
|
||||||
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
|
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
|
||||||
log.Info().
|
log.Info().
|
||||||
Uint("task_id", task.ID).
|
Uint("task_id", task.ID).
|
||||||
Bool("frequency_nil", task.Frequency == nil).
|
Bool("has_frequency", task.FrequencyID != nil).
|
||||||
Msg("QuickComplete: One-time task, clearing next_due_date")
|
Msg("QuickComplete: One-time task, clearing next_due_date")
|
||||||
task.NextDueDate = nil
|
task.NextDueDate = nil
|
||||||
task.InProgress = false
|
task.InProgress = false
|
||||||
} else {
|
} else {
|
||||||
// Recurring task - calculate next due date from completion date + interval
|
// Recurring task - calculate next due date from completion date + interval
|
||||||
nextDue := completedAt.AddDate(0, 0, *quickIntervalDays)
|
nextDue := completedAt.AddDate(0, 0, *quickIntervalDays)
|
||||||
frequencyName := "unknown"
|
// frequencyName was already set when loading frequency above
|
||||||
if task.Frequency != nil {
|
|
||||||
frequencyName = task.Frequency.Name
|
|
||||||
}
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Uint("task_id", task.ID).
|
Uint("task_id", task.ID).
|
||||||
Str("frequency_name", frequencyName).
|
Str("frequency_name", frequencyName).
|
||||||
@@ -780,17 +783,12 @@ func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskC
|
|||||||
|
|
||||||
// ListCompletions lists all task completions for a user
|
// ListCompletions lists all task completions for a user
|
||||||
func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionResponse, error) {
|
func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionResponse, error) {
|
||||||
// Get all residence IDs
|
// Get all residence IDs (lightweight - no preloads)
|
||||||
residences, err := s.residenceRepo.FindByUser(userID)
|
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
residenceIDs := make([]uint, len(residences))
|
|
||||||
for i, r := range residences {
|
|
||||||
residenceIDs[i] = r.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(residenceIDs) == 0 {
|
if len(residenceIDs) == 0 {
|
||||||
return []responses.TaskCompletionResponse{}, nil
|
return []responses.TaskCompletionResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +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.Data.Category)
|
// Note: Category and Priority are no longer preloaded for performance
|
||||||
assert.NotNil(t, resp.Data.Priority)
|
// Client resolves from cache using CategoryID and PriorityID
|
||||||
|
assert.NotNil(t, resp.Data.CategoryID, "CategoryID should be set")
|
||||||
|
assert.NotNil(t, resp.Data.PriorityID, "PriorityID should be set")
|
||||||
assert.NotNil(t, resp.Data.DueDate)
|
assert.NotNil(t, resp.Data.DueDate)
|
||||||
assert.NotNil(t, resp.Data.EstimatedCost)
|
assert.NotNil(t, resp.Data.EstimatedCost)
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ func TestTaskService_ListTasks(t *testing.T) {
|
|||||||
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
|
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
|
||||||
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
|
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
|
||||||
|
|
||||||
resp, err := service.ListTasks(user.ID)
|
resp, err := service.ListTasks(user.ID, time.Now().UTC())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
// ListTasks returns a KanbanBoardResponse with columns
|
// ListTasks returns a KanbanBoardResponse with columns
|
||||||
// Count total tasks across all columns
|
// Count total tasks across all columns
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import (
|
|||||||
//
|
//
|
||||||
// next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
|
// next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id)
|
||||||
func IsCompleted(task *models.Task) bool {
|
func IsCompleted(task *models.Task) bool {
|
||||||
return task.NextDueDate == nil && len(task.Completions) > 0
|
return task.NextDueDate == nil && HasCompletions(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsActive returns true if the task is not cancelled and not archived.
|
// IsActive returns true if the task is not cancelled and not archived.
|
||||||
@@ -165,14 +165,28 @@ func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// HasCompletions returns true if the task has at least one completion record.
|
// HasCompletions returns true if the task has at least one completion record.
|
||||||
|
// Supports both preloaded Completions slice and computed CompletionCount field
|
||||||
|
// for optimized queries that use COUNT subqueries instead of preloading.
|
||||||
func HasCompletions(task *models.Task) bool {
|
func HasCompletions(task *models.Task) bool {
|
||||||
return len(task.Completions) > 0
|
// If Completions were preloaded, use the slice
|
||||||
|
if len(task.Completions) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Otherwise check the computed count (populated via subquery for optimized queries)
|
||||||
|
return task.CompletionCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompletionCount returns the number of completions for a task.
|
// GetCompletionCount returns the number of completions for a task.
|
||||||
func CompletionCount(task *models.Task) int {
|
// Supports both preloaded Completions slice and computed CompletionCount field
|
||||||
|
// for optimized queries that use COUNT subqueries instead of preloading.
|
||||||
|
func GetCompletionCount(task *models.Task) int {
|
||||||
|
// If Completions were preloaded, use the slice length
|
||||||
|
if len(task.Completions) > 0 {
|
||||||
return len(task.Completions)
|
return len(task.Completions)
|
||||||
}
|
}
|
||||||
|
// Otherwise return the computed count (populated via subquery for optimized queries)
|
||||||
|
return task.CompletionCount
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// RECURRING TASK HELPERS
|
// RECURRING TASK HELPERS
|
||||||
|
|||||||
@@ -109,9 +109,10 @@ func HasCompletions(task *models.Task) bool {
|
|||||||
return predicates.HasCompletions(task)
|
return predicates.HasCompletions(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompletionCount returns the number of completions for a task.
|
// GetCompletionCount returns the number of completions for a task.
|
||||||
func CompletionCount(task *models.Task) int {
|
// Supports both preloaded Completions slice and computed CompletionCount field.
|
||||||
return predicates.CompletionCount(task)
|
func GetCompletionCount(task *models.Task) int {
|
||||||
|
return predicates.GetCompletionCount(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRecurring returns true if the task has a recurring frequency.
|
// IsRecurring returns true if the task has a recurring frequency.
|
||||||
|
|||||||
23
migrations/006_performance_indexes.down.sql
Normal file
23
migrations/006_performance_indexes.down.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- Rollback performance optimization indexes
|
||||||
|
-- Migration: 006_performance_indexes
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_user_email_lower;
|
||||||
|
DROP INDEX IF EXISTS idx_user_username_lower;
|
||||||
|
DROP INDEX IF EXISTS idx_admin_email_lower;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_task_residence_status;
|
||||||
|
DROP INDEX IF EXISTS idx_task_residence_active;
|
||||||
|
DROP INDEX IF EXISTS idx_task_next_due_date;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_notification_user_read;
|
||||||
|
DROP INDEX IF EXISTS idx_notification_user_sent;
|
||||||
|
DROP INDEX IF EXISTS idx_notification_task;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_document_residence_active_type;
|
||||||
|
DROP INDEX IF EXISTS idx_document_expiry_active;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_contractor_created_by;
|
||||||
|
DROP INDEX IF EXISTS idx_completion_task;
|
||||||
|
DROP INDEX IF EXISTS idx_completion_completed_by;
|
||||||
|
DROP INDEX IF EXISTS idx_residence_member_user;
|
||||||
|
DROP INDEX IF EXISTS idx_share_code_active;
|
||||||
75
migrations/006_performance_indexes.up.sql
Normal file
75
migrations/006_performance_indexes.up.sql
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
-- Performance optimization indexes
|
||||||
|
-- Migration: 006_performance_indexes
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- CRITICAL: Case-insensitive indexes for auth lookups
|
||||||
|
-- =====================================================
|
||||||
|
-- These eliminate full table scans on login/registration
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_email_lower ON auth_user (LOWER(email));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_username_lower ON auth_user (LOWER(username));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_email_lower ON admin_users (LOWER(email));
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- HIGH PRIORITY: Composite indexes for common queries
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Tasks: Most common query pattern is by residence with status filters
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_residence_status
|
||||||
|
ON task_task (residence_id, is_cancelled, is_archived);
|
||||||
|
|
||||||
|
-- Tasks: For kanban board queries (active tasks by residence)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_residence_active
|
||||||
|
ON task_task (residence_id, is_archived, in_progress)
|
||||||
|
WHERE is_cancelled = false;
|
||||||
|
|
||||||
|
-- Tasks: For overdue queries (next_due_date lookups)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_next_due_date
|
||||||
|
ON task_task (next_due_date)
|
||||||
|
WHERE is_cancelled = false AND is_archived = false AND next_due_date IS NOT NULL;
|
||||||
|
|
||||||
|
-- Notifications: Queried constantly for unread count
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_user_read
|
||||||
|
ON notifications_notification (user_id, read);
|
||||||
|
|
||||||
|
-- Notifications: For pending notification worker
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_user_sent
|
||||||
|
ON notifications_notification (user_id, sent);
|
||||||
|
|
||||||
|
-- Notifications: Task-based lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_task
|
||||||
|
ON notifications_notification (task_id)
|
||||||
|
WHERE task_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Documents: Warranty expiry queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_residence_active_type
|
||||||
|
ON task_document (residence_id, is_active, document_type);
|
||||||
|
|
||||||
|
-- Documents: Expiring warranties lookup
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_expiry_active
|
||||||
|
ON task_document (expiry_date, is_active)
|
||||||
|
WHERE document_type = 'warranty' AND is_active = true;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- MEDIUM PRIORITY: Foreign key indexes
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Contractor: Query by creator (user's personal contractors)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contractor_created_by
|
||||||
|
ON task_contractor (created_by_id);
|
||||||
|
|
||||||
|
-- Task completions: Query by task
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completion_task
|
||||||
|
ON task_taskcompletion (task_id);
|
||||||
|
|
||||||
|
-- Task completions: Query by user who completed
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completion_completed_by
|
||||||
|
ON task_taskcompletion (completed_by_id);
|
||||||
|
|
||||||
|
-- Residence members: Query by user
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_residence_member_user
|
||||||
|
ON residence_residencemember (user_id);
|
||||||
|
|
||||||
|
-- Share codes: Active code lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_share_code_active
|
||||||
|
ON residence_residencesharecode (code, is_active)
|
||||||
|
WHERE is_active = true;
|
||||||
5
migrations/007_custom_interval_days.down.sql
Normal file
5
migrations/007_custom_interval_days.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Rollback custom_interval_days column
|
||||||
|
-- Migration: 007_custom_interval_days
|
||||||
|
|
||||||
|
ALTER TABLE task_task
|
||||||
|
DROP COLUMN IF EXISTS custom_interval_days;
|
||||||
8
migrations/007_custom_interval_days.up.sql
Normal file
8
migrations/007_custom_interval_days.up.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Add custom_interval_days for custom frequency tasks
|
||||||
|
-- Migration: 007_custom_interval_days
|
||||||
|
|
||||||
|
ALTER TABLE task_task
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_interval_days INTEGER;
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN task_task.custom_interval_days IS 'For Custom frequency tasks, the user-specified number of days between occurrences';
|
||||||
8
migrations/008_additional_performance_indexes.down.sql
Normal file
8
migrations/008_additional_performance_indexes.down.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Rollback additional performance optimization indexes
|
||||||
|
-- Migration: 008_additional_performance_indexes
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_task_kanban_composite;
|
||||||
|
DROP INDEX IF EXISTS idx_completion_task_date;
|
||||||
|
DROP INDEX IF EXISTS idx_sharecode_code_active;
|
||||||
|
DROP INDEX IF EXISTS idx_residence_users_user_residence;
|
||||||
|
DROP INDEX IF EXISTS idx_task_in_progress;
|
||||||
47
migrations/008_additional_performance_indexes.up.sql
Normal file
47
migrations/008_additional_performance_indexes.up.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Additional performance optimization indexes
|
||||||
|
-- Migration: 008_additional_performance_indexes
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- KANBAN QUERY OPTIMIZATION
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Composite index for kanban board queries
|
||||||
|
-- Covers: WHERE residence_id IN ? AND is_archived = false
|
||||||
|
-- with ordering by due_date, next_due_date
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_kanban_composite
|
||||||
|
ON task_task (residence_id, is_archived, is_cancelled, next_due_date, due_date)
|
||||||
|
WHERE is_archived = false;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- COMPLETION QUERY OPTIMIZATION
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Ordering index for completion queries (most recent first)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completion_task_date
|
||||||
|
ON task_taskcompletion (task_id, completed_at DESC);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- SHARE CODE OPTIMIZATION
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Unique index for active share code lookups
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sharecode_code_active
|
||||||
|
ON residence_residencesharecode (code)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- RESIDENCE USER ACCESS OPTIMIZATION
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Index for residence user membership queries (used by FindResidenceIDsByUser)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_residence_users_user_residence
|
||||||
|
ON residence_residence_users (user_id, residence_id);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- TASK IN_PROGRESS QUERIES
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Index for in_progress task queries (kanban "In Progress" column)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_in_progress
|
||||||
|
ON task_task (residence_id, in_progress)
|
||||||
|
WHERE in_progress = true AND is_cancelled = false AND is_archived = false;
|
||||||
10
migrations/009_remove_redundant_indexes.down.sql
Normal file
10
migrations/009_remove_redundant_indexes.down.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Rollback: Recreate removed indexes
|
||||||
|
|
||||||
|
-- Recreate share code index (from migration 006)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_share_code_active
|
||||||
|
ON residence_residencesharecode (residence_id)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- Recreate notification user+sent index (from migration 006)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_user_sent
|
||||||
|
ON notifications_notification (user_id, sent);
|
||||||
15
migrations/009_remove_redundant_indexes.up.sql
Normal file
15
migrations/009_remove_redundant_indexes.up.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Migration: 009_remove_redundant_indexes
|
||||||
|
-- Description: Remove indexes that are redundant or unused
|
||||||
|
|
||||||
|
-- Remove redundant share code index
|
||||||
|
-- idx_share_code_active is superseded by unique idx_sharecode_code_active (migration 008)
|
||||||
|
-- Both filter WHERE is_active = true, but 008's unique index on (code) is more restrictive
|
||||||
|
DROP INDEX IF EXISTS idx_share_code_active;
|
||||||
|
|
||||||
|
-- Remove unused composite notification index
|
||||||
|
-- idx_notification_user_sent on (user_id, sent) is never used:
|
||||||
|
-- - GetPendingNotifications() only filters on sent, not user_id
|
||||||
|
-- - FindByUser() only filters on user_id, not sent (uses idx_notification_user_created_at)
|
||||||
|
-- - No queries combine user_id AND sent together
|
||||||
|
-- The leading column (user_id) queries already use idx_notification_user_created_at
|
||||||
|
DROP INDEX IF EXISTS idx_notification_user_sent;
|
||||||
Reference in New Issue
Block a user