Add PDF reports, file uploads, admin auth, and comprehensive tests

Features:
- PDF service for generating task reports with ReportLab-style formatting
- Storage service for file uploads (local and S3-compatible)
- Admin authentication middleware with JWT support
- Admin user model and repository

Infrastructure:
- Updated Docker configuration for admin panel builds
- Email service enhancements for task notifications
- Updated router with admin and file upload routes
- Environment configuration updates

Tests:
- Unit tests for handlers (auth, residence, task)
- Unit tests for models (user, residence, task)
- Unit tests for repositories (user, residence, task)
- Unit tests for services (residence, task)
- Integration test setup
- Test utilities for mocking database and services

Database:
- Admin user seed data
- Updated test data seeds

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-27 23:36:20 -06:00
parent 2817deee3c
commit 469f21a833
50 changed files with 6795 additions and 582 deletions

View File

@@ -251,6 +251,132 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
}, nil
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int) (*models.KanbanBoard, error) {
var tasks []models.Task
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Status").
Preload("Frequency").
Preload("Completions").
Preload("Completions.CompletedBy").
Preload("Residence").
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC").
Find(&tasks).Error
if err != nil {
return nil, err
}
// Organize into columns
now := time.Now().UTC()
threshold := now.AddDate(0, 0, daysThreshold)
overdue := make([]models.Task, 0)
dueSoon := make([]models.Task, 0)
upcoming := make([]models.Task, 0)
inProgress := make([]models.Task, 0)
completed := make([]models.Task, 0)
cancelled := make([]models.Task, 0)
for _, task := range tasks {
if task.IsCancelled {
cancelled = append(cancelled, task)
continue
}
// Check if completed (has completions)
if len(task.Completions) > 0 {
completed = append(completed, task)
continue
}
// Check status for in-progress
if task.Status != nil && task.Status.Name == "In Progress" {
inProgress = append(inProgress, task)
continue
}
// Check due date
if task.DueDate != nil {
if task.DueDate.Before(now) {
overdue = append(overdue, task)
} else if task.DueDate.Before(threshold) {
dueSoon = append(dueSoon, task)
} else {
upcoming = append(upcoming, task)
}
} else {
upcoming = append(upcoming, task)
}
}
columns := []models.KanbanColumn{
{
Name: "overdue_tasks",
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: overdue,
Count: len(overdue),
},
{
Name: "due_soon_tasks",
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: dueSoon,
Count: len(dueSoon),
},
{
Name: "upcoming_tasks",
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "cancel"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: upcoming,
Count: len(upcoming),
},
{
Name: "in_progress_tasks",
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: inProgress,
Count: len(inProgress),
},
{
Name: "completed_tasks",
DisplayName: "Completed",
ButtonTypes: []string{"view"},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: completed,
Count: len(completed),
},
{
Name: "cancelled_tasks",
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: cancelled,
Count: len(cancelled),
},
}
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: "all",
}, nil
}
// === Lookup Operations ===
// GetAllCategories returns all task categories
@@ -345,3 +471,79 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
func (r *TaskRepository) DeleteCompletion(id uint) error {
return r.db.Delete(&models.TaskCompletion{}, id).Error
}
// TaskStatistics represents aggregated task statistics
type TaskStatistics struct {
TotalTasks int
TotalPending int
TotalOverdue int
TasksDueNextWeek int
TasksDueNextMonth int
}
// GetTaskStatistics returns aggregated task statistics for multiple residences
func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics, error) {
if len(residenceIDs) == 0 {
return &TaskStatistics{}, nil
}
now := time.Now().UTC()
nextWeek := now.AddDate(0, 0, 7)
nextMonth := now.AddDate(0, 1, 0)
var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64
// Count total active tasks (not cancelled, not archived)
err := r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Count(&totalTasks).Error
if err != nil {
return nil, err
}
// Count overdue tasks (due date < now, no completions)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ? AND due_date < ?", residenceIDs, false, false, now).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&totalOverdue).Error
if err != nil {
return nil, err
}
// Count pending tasks (not completed, not cancelled, not archived)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&totalPending).Error
if err != nil {
return nil, err
}
// Count tasks due next week (due date between now and 7 days, not completed)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Where("due_date >= ? AND due_date < ?", now, nextWeek).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&tasksDueNextWeek).Error
if err != nil {
return nil, err
}
// Count tasks due next month (due date between now and 30 days, not completed)
err = r.db.Model(&models.Task{}).
Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false).
Where("due_date >= ? AND due_date < ?", now, nextMonth).
Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")).
Count(&tasksDueNextMonth).Error
if err != nil {
return nil, err
}
return &TaskStatistics{
TotalTasks: int(totalTasks),
TotalPending: int(totalPending),
TotalOverdue: int(totalOverdue),
TasksDueNextWeek: int(tasksDueNextWeek),
TasksDueNextMonth: int(tasksDueNextMonth),
}, nil
}