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:
Trey t
2025-12-14 01:06:08 -06:00
parent 2ea5cea936
commit 0cf64cfb0c
22 changed files with 436 additions and 203 deletions

View File

@@ -70,16 +70,12 @@ func (s *ContractorService) hasContractorAccess(contractor *models.Contractor, u
// ListContractors lists all contractors accessible to a user
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 {
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
contractors, err := s.contractorRepo.FindByUser(userID, residenceIDs)
if err != nil {

View File

@@ -56,16 +56,12 @@ func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.Docum
// ListDocuments lists all documents accessible to a user
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 {
return nil, err
}
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
if len(residenceIDs) == 0 {
return []responses.DocumentResponse{}, nil
}
@@ -80,16 +76,12 @@ func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentRespon
// ListWarranties lists all warranty documents
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 {
return nil, err
}
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
if len(residenceIDs) == 0 {
return []responses.DocumentResponse{}, nil
}

View File

@@ -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.
// 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) {
residences, err := s.residenceRepo.FindByUser(userID)
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
}
summary := &responses.TotalSummary{
TotalResidences: len(residences),
TotalResidences: len(residenceIDs),
}
// Get task statistics if task repository is available
if s.taskRepo != nil && len(residences) > 0 {
// Collect residence IDs
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
if s.taskRepo != nil && len(residenceIDs) > 0 {
// Get aggregated statistics using user's timezone-aware time
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
if err == nil && stats != nil {
@@ -167,14 +162,14 @@ func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.To
return summary, nil
}
// getSummaryForUser is a helper that returns summary for a user, or empty summary on error.
// Uses UTC time. For timezone-aware summary, use GetSummary directly.
func (s *ResidenceService) getSummaryForUser(userID uint) responses.TotalSummary {
summary, err := s.GetSummary(userID, time.Now().UTC())
if err != nil || summary == nil {
return responses.TotalSummary{}
}
return *summary
// getSummaryForUser returns an empty summary placeholder.
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
// Clients should calculate summary from kanban data instead (which already includes all tasks).
// The summary field is kept in responses for backward compatibility but will always be empty.
// 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{}
}
// CreateResidence creates a new residence and returns it with updated summary

View File

@@ -533,13 +533,14 @@ func TestRecurringTask_Lifecycle_FirstCompletion(t *testing.T) {
err = taskRepo.CreateCompletion(completion)
require.NoError(t, err)
// Reload to get frequency
// Reload task
task, err = taskRepo.FindByID(task.ID)
require.NoError(t, err)
// Simulate the next_due_date update (from task_service.CreateCompletion)
// 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
err = taskRepo.Update(task)
require.NoError(t, err)
@@ -603,7 +604,8 @@ func TestRecurringTask_Lifecycle_MultipleCompletions(t *testing.T) {
})
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
taskRepo.Update(task)
@@ -632,7 +634,8 @@ func TestRecurringTask_Lifecycle_MultipleCompletions(t *testing.T) {
})
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
taskRepo.Update(task)
@@ -725,7 +728,7 @@ func TestKanbanBoard_OneTimeTaskCompletion_MovesToCompleted(t *testing.T) {
db.Create(task)
// 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)
dueSoonCount := 0
@@ -751,7 +754,7 @@ func TestKanbanBoard_OneTimeTaskCompletion_MovesToCompleted(t *testing.T) {
db.Save(task)
// 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)
for _, col := range board.Columns {
@@ -790,7 +793,7 @@ func TestKanbanBoard_RecurringTaskCompletion_StaysActionable(t *testing.T) {
db.Create(task)
// 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)
var overdueCount, completedCount, dueSoonCount int
@@ -821,7 +824,7 @@ func TestKanbanBoard_RecurringTaskCompletion_StaysActionable(t *testing.T) {
db.Save(task)
// 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)
for _, col := range board.Columns {

View File

@@ -55,17 +55,13 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) {
s.residenceService = rs
}
// getSummaryForUser gets the total summary for a user (helper for CRUD responses).
// Uses UTC time. For timezone-aware summary, call residence service directly.
func (s *TaskService) getSummaryForUser(userID uint) responses.TotalSummary {
if s.residenceService == nil {
return responses.TotalSummary{}
}
summary, err := s.residenceService.GetSummary(userID, time.Now().UTC())
if err != nil || summary == nil {
return responses.TotalSummary{}
}
return *summary
// getSummaryForUser returns an empty summary placeholder.
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
// Clients should calculate summary from kanban data instead (which already includes all tasks).
// 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{}
}
// === 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.
// 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) {
// Get all residence IDs accessible to user
residences, err := s.residenceRepo.FindByUser(userID)
// Get all residence IDs accessible to user (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
}
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
if len(residenceIDs) == 0 {
// Return empty kanban board
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 recurring, calculate next_due_date = completion_date + frequency_days
// 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
if task.Frequency != nil && task.Frequency.Name == "Custom" {
// Custom frequency - use task's custom_interval_days
intervalDays = task.CustomIntervalDays
} else if task.Frequency != nil {
// Standard frequency - use frequency's days
intervalDays = task.Frequency.Days
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
intervalDays = task.CustomIntervalDays
} else {
// Standard frequency - use frequency's days
intervalDays = frequency.Days
}
}
}
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
// 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
if task.Frequency != nil && task.Frequency.Name == "Custom" {
quickIntervalDays = task.CustomIntervalDays
} else if task.Frequency != nil {
quickIntervalDays = task.Frequency.Days
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
} else {
quickIntervalDays = frequency.Days
}
}
}
if quickIntervalDays == nil || *quickIntervalDays == 0 {
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
log.Info().
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")
task.NextDueDate = nil
task.InProgress = false
} else {
// Recurring task - calculate next due date from completion date + interval
nextDue := completedAt.AddDate(0, 0, *quickIntervalDays)
frequencyName := "unknown"
if task.Frequency != nil {
frequencyName = task.Frequency.Name
}
// frequencyName was already set when loading frequency above
log.Info().
Uint("task_id", task.ID).
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
func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionResponse, error) {
// Get all residence IDs
residences, err := s.residenceRepo.FindByUser(userID)
// Get all residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
}
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
if len(residenceIDs) == 0 {
return []responses.TaskCompletionResponse{}, nil
}

View File

@@ -76,8 +76,10 @@ func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
resp, err := service.CreateTask(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp.Data.Category)
assert.NotNil(t, resp.Data.Priority)
// Note: Category and Priority are no longer preloaded for performance
// 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.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 3")
resp, err := service.ListTasks(user.ID)
resp, err := service.ListTasks(user.ID, time.Now().UTC())
require.NoError(t, err)
// ListTasks returns a KanbanBoardResponse with columns
// Count total tasks across all columns