Add actionable push notifications and fix recurring task completion
Features: - Add task action buttons to push notifications (complete, view, cancel, etc.) - Add button types logic for different task states (overdue, in_progress, etc.) - Implement Chain of Responsibility pattern for task categorization - Add comprehensive kanban categorization documentation Fixes: - Reset recurring task status to Pending after completion so tasks appear in correct kanban column (was staying in "In Progress") - Fix PostgreSQL EXTRACT function error in overdue notifications query - Update seed data to properly set next_due_date for recurring tasks Admin: - Add tasks list to residence detail page - Fix task edit page to properly handle all fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -169,7 +169,6 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
|
||||
return
|
||||
}
|
||||
task.ResidenceID = *req.ResidenceID
|
||||
}
|
||||
// Verify created_by if changing
|
||||
if req.CreatedByID != nil {
|
||||
@@ -178,7 +177,6 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Created by user not found"})
|
||||
return
|
||||
}
|
||||
task.CreatedByID = *req.CreatedByID
|
||||
}
|
||||
// Verify assigned_to if changing
|
||||
if req.AssignedToID != nil {
|
||||
@@ -187,57 +185,68 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Assigned to user not found"})
|
||||
return
|
||||
}
|
||||
task.AssignedToID = req.AssignedToID
|
||||
}
|
||||
|
||||
// Build update map with only the fields that were provided
|
||||
updates := make(map[string]interface{})
|
||||
if req.ResidenceID != nil {
|
||||
updates["residence_id"] = *req.ResidenceID
|
||||
}
|
||||
if req.CreatedByID != nil {
|
||||
updates["created_by_id"] = *req.CreatedByID
|
||||
}
|
||||
if req.AssignedToID != nil {
|
||||
updates["assigned_to_id"] = *req.AssignedToID
|
||||
}
|
||||
if req.Title != nil {
|
||||
task.Title = *req.Title
|
||||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
task.Description = *req.Description
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.CategoryID != nil {
|
||||
task.CategoryID = req.CategoryID
|
||||
updates["category_id"] = *req.CategoryID
|
||||
}
|
||||
if req.PriorityID != nil {
|
||||
task.PriorityID = req.PriorityID
|
||||
updates["priority_id"] = *req.PriorityID
|
||||
}
|
||||
if req.StatusID != nil {
|
||||
task.StatusID = req.StatusID
|
||||
updates["status_id"] = *req.StatusID
|
||||
}
|
||||
if req.FrequencyID != nil {
|
||||
task.FrequencyID = req.FrequencyID
|
||||
updates["frequency_id"] = *req.FrequencyID
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
if dueDate, err := time.Parse("2006-01-02", *req.DueDate); err == nil {
|
||||
task.DueDate = &dueDate
|
||||
updates["due_date"] = dueDate
|
||||
}
|
||||
}
|
||||
if req.EstimatedCost != nil {
|
||||
d := decimal.NewFromFloat(*req.EstimatedCost)
|
||||
task.EstimatedCost = &d
|
||||
updates["estimated_cost"] = decimal.NewFromFloat(*req.EstimatedCost)
|
||||
}
|
||||
if req.ActualCost != nil {
|
||||
d := decimal.NewFromFloat(*req.ActualCost)
|
||||
task.ActualCost = &d
|
||||
updates["actual_cost"] = decimal.NewFromFloat(*req.ActualCost)
|
||||
}
|
||||
if req.ContractorID != nil {
|
||||
task.ContractorID = req.ContractorID
|
||||
updates["contractor_id"] = *req.ContractorID
|
||||
}
|
||||
if req.ParentTaskID != nil {
|
||||
task.ParentTaskID = req.ParentTaskID
|
||||
updates["parent_task_id"] = *req.ParentTaskID
|
||||
}
|
||||
if req.IsCancelled != nil {
|
||||
task.IsCancelled = *req.IsCancelled
|
||||
updates["is_cancelled"] = *req.IsCancelled
|
||||
}
|
||||
if req.IsArchived != nil {
|
||||
task.IsArchived = *req.IsArchived
|
||||
updates["is_archived"] = *req.IsArchived
|
||||
}
|
||||
|
||||
if err := h.db.Save(&task).Error; err != nil {
|
||||
// Use Updates with map to only update specified fields
|
||||
if err := h.db.Model(&task).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update task"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads for response
|
||||
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, id)
|
||||
c.JSON(http.StatusOK, h.toTaskResponse(&task))
|
||||
}
|
||||
@@ -320,7 +329,7 @@ func (h *AdminTaskHandler) Delete(c *gin.Context) {
|
||||
// Soft delete - archive and cancel
|
||||
task.IsArchived = true
|
||||
task.IsCancelled = true
|
||||
if err := h.db.Save(&task).Error; err != nil {
|
||||
if err := h.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Status", "Frequency", "ParentTask", "Completions").Save(&task).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ type TaskResponse struct {
|
||||
FrequencyID *uint `json:"frequency_id"`
|
||||
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion
|
||||
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
|
||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||
ContractorID *uint `json:"contractor_id"`
|
||||
@@ -229,7 +230,13 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
|
||||
}
|
||||
|
||||
// NewTaskResponse creates a TaskResponse from a Task model
|
||||
// Always includes kanban_column using default 30-day threshold
|
||||
func NewTaskResponse(t *models.Task) TaskResponse {
|
||||
return NewTaskResponseWithThreshold(t, 30)
|
||||
}
|
||||
|
||||
// NewTaskResponseWithThreshold creates a TaskResponse with a custom days threshold for kanban column
|
||||
func NewTaskResponseWithThreshold(t *models.Task, daysThreshold int) TaskResponse {
|
||||
resp := TaskResponse{
|
||||
ID: t.ID,
|
||||
ResidenceID: t.ResidenceID,
|
||||
@@ -242,6 +249,7 @@ func NewTaskResponse(t *models.Task) TaskResponse {
|
||||
FrequencyID: t.FrequencyID,
|
||||
AssignedToID: t.AssignedToID,
|
||||
DueDate: t.DueDate,
|
||||
NextDueDate: t.NextDueDate,
|
||||
EstimatedCost: t.EstimatedCost,
|
||||
ActualCost: t.ActualCost,
|
||||
ContractorID: t.ContractorID,
|
||||
@@ -249,6 +257,7 @@ func NewTaskResponse(t *models.Task) TaskResponse {
|
||||
IsArchived: t.IsArchived,
|
||||
ParentTaskID: t.ParentTaskID,
|
||||
CompletionCount: len(t.Completions),
|
||||
KanbanColumn: DetermineKanbanColumn(t, daysThreshold),
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
}
|
||||
@@ -348,17 +357,23 @@ func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Ta
|
||||
resp := NewTaskCompletionResponse(c)
|
||||
|
||||
if task != nil {
|
||||
taskResp := NewTaskResponse(task)
|
||||
taskResp.KanbanColumn = DetermineKanbanColumn(task, daysThreshold)
|
||||
taskResp := NewTaskResponseWithThreshold(task, daysThreshold)
|
||||
resp.Task = &taskResp
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// DetermineKanbanColumn determines which kanban column a task belongs to
|
||||
// Uses the same logic as task_repo.go GetKanbanData
|
||||
// DetermineKanbanColumn determines which kanban column a task belongs to.
|
||||
// This is a wrapper around the Chain of Responsibility implementation in
|
||||
// internal/task/categorization package. See that package for detailed
|
||||
// documentation on the categorization logic.
|
||||
//
|
||||
// Deprecated: Use categorization.DetermineKanbanColumn directly for new code.
|
||||
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
||||
// Import would cause circular dependency, so we replicate the logic here
|
||||
// for backwards compatibility. The authoritative implementation is in
|
||||
// internal/task/categorization/chain.go
|
||||
if daysThreshold <= 0 {
|
||||
daysThreshold = 30 // Default
|
||||
}
|
||||
@@ -366,31 +381,37 @@ func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
||||
now := time.Now().UTC()
|
||||
threshold := now.AddDate(0, 0, daysThreshold)
|
||||
|
||||
// Priority order (same as GetKanbanData):
|
||||
// 1. Cancelled
|
||||
// Priority order (Chain of Responsibility):
|
||||
// 1. Cancelled (highest priority)
|
||||
if task.IsCancelled {
|
||||
return "cancelled_tasks"
|
||||
}
|
||||
|
||||
// 2. Completed (has completions)
|
||||
if len(task.Completions) > 0 {
|
||||
// 2. Completed (one-time task with nil next_due_date and has completions)
|
||||
if task.NextDueDate == nil && len(task.Completions) > 0 {
|
||||
return "completed_tasks"
|
||||
}
|
||||
|
||||
// 3. In Progress
|
||||
// 3. In Progress (status check)
|
||||
if task.Status != nil && task.Status.Name == "In Progress" {
|
||||
return "in_progress_tasks"
|
||||
}
|
||||
|
||||
// 4. Due date based
|
||||
if task.DueDate != nil {
|
||||
if task.DueDate.Before(now) {
|
||||
// 4. Overdue (next_due_date or due_date is in the past)
|
||||
effectiveDate := task.NextDueDate
|
||||
if effectiveDate == nil {
|
||||
effectiveDate = task.DueDate
|
||||
}
|
||||
if effectiveDate != nil {
|
||||
if effectiveDate.Before(now) {
|
||||
return "overdue_tasks"
|
||||
} else if task.DueDate.Before(threshold) {
|
||||
}
|
||||
// 5. Due Soon (within threshold)
|
||||
if effectiveDate.Before(threshold) {
|
||||
return "due_soon_tasks"
|
||||
}
|
||||
}
|
||||
|
||||
// Default: upcoming
|
||||
// 6. Upcoming (default/fallback)
|
||||
return "upcoming_tasks"
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ type Task struct {
|
||||
Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"`
|
||||
|
||||
DueDate *time.Time `gorm:"column:due_date;type:date;index" json:"due_date"`
|
||||
NextDueDate *time.Time `gorm:"column:next_due_date;type:date;index" json:"next_due_date"` // For recurring tasks, updated after each completion
|
||||
EstimatedCost *decimal.Decimal `gorm:"column:estimated_cost;type:decimal(10,2)" json:"estimated_cost"`
|
||||
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`
|
||||
|
||||
|
||||
@@ -125,6 +125,80 @@ func (c *APNsClient) Send(ctx context.Context, tokens []string, title, message s
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendWithCategory sends a push notification with iOS category for actionable notifications
|
||||
func (c *APNsClient) SendWithCategory(ctx context.Context, tokens []string, title, message string, data map[string]string, categoryID string) error {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the notification payload with category
|
||||
p := payload.NewPayload().
|
||||
AlertTitle(title).
|
||||
AlertBody(message).
|
||||
Sound("default").
|
||||
MutableContent().
|
||||
Category(categoryID) // iOS category for actionable notifications
|
||||
|
||||
// Add custom data
|
||||
for key, value := range data {
|
||||
p.Custom(key, value)
|
||||
}
|
||||
|
||||
var errors []error
|
||||
successCount := 0
|
||||
|
||||
for _, deviceToken := range tokens {
|
||||
notification := &apns2.Notification{
|
||||
DeviceToken: deviceToken,
|
||||
Topic: c.topic,
|
||||
Payload: p,
|
||||
Priority: apns2.PriorityHigh,
|
||||
}
|
||||
|
||||
res, err := c.client.PushWithContext(ctx, notification)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("category", categoryID).
|
||||
Msg("Failed to send APNs actionable notification")
|
||||
errors = append(errors, fmt.Errorf("token %s: %w", truncateToken(deviceToken), err))
|
||||
continue
|
||||
}
|
||||
|
||||
if !res.Sent() {
|
||||
log.Error().
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("reason", res.Reason).
|
||||
Int("status", res.StatusCode).
|
||||
Str("category", categoryID).
|
||||
Msg("APNs actionable notification not sent")
|
||||
errors = append(errors, fmt.Errorf("token %s: %s (status %d)", truncateToken(deviceToken), res.Reason, res.StatusCode))
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
log.Debug().
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("apns_id", res.ApnsID).
|
||||
Str("category", categoryID).
|
||||
Msg("APNs actionable notification sent successfully")
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("total", len(tokens)).
|
||||
Int("success", successCount).
|
||||
Int("failed", len(errors)).
|
||||
Str("category", categoryID).
|
||||
Msg("APNs actionable batch send complete")
|
||||
|
||||
if len(errors) > 0 && successCount == 0 {
|
||||
return fmt.Errorf("all APNs actionable notifications failed: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateToken returns first 8 chars of token for logging
|
||||
func truncateToken(token string) string {
|
||||
if len(token) > 8 {
|
||||
|
||||
@@ -102,6 +102,33 @@ func (c *Client) IsAndroidEnabled() bool {
|
||||
return c.fcm != nil
|
||||
}
|
||||
|
||||
// SendActionableNotification sends notifications with action button support
|
||||
// iOS receives a category for actionable notifications, Android handles actions via data payload
|
||||
func (c *Client) SendActionableNotification(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string, iosCategoryID string) error {
|
||||
var lastErr error
|
||||
|
||||
if len(iosTokens) > 0 {
|
||||
if c.apns == nil {
|
||||
log.Warn().Msg("APNs client not initialized, skipping iOS actionable push")
|
||||
} else {
|
||||
if err := c.apns.SendWithCategory(ctx, iosTokens, title, message, data, iosCategoryID); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send iOS actionable notifications")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(androidTokens) > 0 {
|
||||
// Android handles actions via data payload - existing send works
|
||||
if err := c.SendToAndroid(ctx, androidTokens, title, message, data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send Android notifications")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// HealthCheck checks if the push services are available
|
||||
func (c *Client) HealthCheck(ctx context.Context) error {
|
||||
// For direct clients, we can't easily health check without sending a notification
|
||||
|
||||
@@ -73,8 +73,9 @@ func (r *ContractorRepository) Create(contractor *models.Contractor) error {
|
||||
}
|
||||
|
||||
// Update updates a contractor
|
||||
// Uses Omit to exclude associations that could interfere with Save
|
||||
func (r *ContractorRepository) Update(contractor *models.Contractor) error {
|
||||
return r.db.Save(contractor).Error
|
||||
return r.db.Omit("CreatedBy", "Specialties", "Tasks", "Residence").Save(contractor).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes a contractor
|
||||
|
||||
@@ -90,8 +90,9 @@ func (r *DocumentRepository) Create(document *models.Document) error {
|
||||
}
|
||||
|
||||
// Update updates a document
|
||||
// Uses Omit to exclude associations that could interfere with Save
|
||||
func (r *DocumentRepository) Update(document *models.Document) error {
|
||||
return r.db.Save(document).Error
|
||||
return r.db.Omit("CreatedBy", "Task", "Images", "Residence").Save(document).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes a document
|
||||
|
||||
@@ -89,8 +89,9 @@ func (r *ResidenceRepository) Create(residence *models.Residence) error {
|
||||
}
|
||||
|
||||
// Update updates a residence
|
||||
// Uses Omit to exclude associations that could interfere with Save
|
||||
func (r *ResidenceRepository) Update(residence *models.Residence) error {
|
||||
return r.db.Save(residence).Error
|
||||
return r.db.Omit("Owner", "Users", "PropertyType").Save(residence).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes a residence by setting is_active to false
|
||||
|
||||
@@ -8,6 +8,14 @@ import (
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// isTaskCompleted determines if a task should be considered "completed" for kanban display.
|
||||
// A task is completed if next_due_date is nil (meaning it was a one-time task that's been completed).
|
||||
// Recurring tasks always have a next_due_date after completion, so they're never "completed" permanently.
|
||||
func isTaskCompleted(task *models.Task) bool {
|
||||
// If next_due_date is nil and task has completions, it's a completed one-time task
|
||||
return task.NextDueDate == nil && len(task.Completions) > 0
|
||||
}
|
||||
|
||||
// TaskRepository handles database operations for tasks
|
||||
type TaskRepository struct {
|
||||
db *gorm.DB
|
||||
@@ -83,8 +91,9 @@ func (r *TaskRepository) Create(task *models.Task) error {
|
||||
}
|
||||
|
||||
// Update updates a task
|
||||
// Uses Omit to exclude associations that shouldn't be updated via Save
|
||||
func (r *TaskRepository) Update(task *models.Task) error {
|
||||
return r.db.Save(task).Error
|
||||
return r.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Status", "Frequency", "ParentTask", "Completions").Save(task).Error
|
||||
}
|
||||
|
||||
// Delete hard-deletes a task
|
||||
@@ -167,8 +176,8 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if completed (has completions)
|
||||
if len(task.Completions) > 0 {
|
||||
// Check if completed (one-time task with nil next_due_date)
|
||||
if isTaskCompleted(&task) {
|
||||
completed = append(completed, task)
|
||||
continue
|
||||
}
|
||||
@@ -179,17 +188,28 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo
|
||||
continue
|
||||
}
|
||||
|
||||
// Check due date
|
||||
if task.DueDate != nil {
|
||||
if task.DueDate.Before(now) {
|
||||
// Use next_due_date for categorization (this handles recurring tasks properly)
|
||||
if task.NextDueDate != nil {
|
||||
if task.NextDueDate.Before(now) {
|
||||
overdue = append(overdue, task)
|
||||
} else if task.DueDate.Before(threshold) {
|
||||
} else if task.NextDueDate.Before(threshold) {
|
||||
dueSoon = append(dueSoon, task)
|
||||
} else {
|
||||
upcoming = append(upcoming, task)
|
||||
}
|
||||
} else {
|
||||
upcoming = append(upcoming, task)
|
||||
// No next_due_date and no completions - use due_date for initial categorization
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,8 +314,8 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if completed (has completions)
|
||||
if len(task.Completions) > 0 {
|
||||
// Check if completed (one-time task with nil next_due_date)
|
||||
if isTaskCompleted(&task) {
|
||||
completed = append(completed, task)
|
||||
continue
|
||||
}
|
||||
@@ -306,17 +326,28 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
|
||||
continue
|
||||
}
|
||||
|
||||
// Check due date
|
||||
if task.DueDate != nil {
|
||||
if task.DueDate.Before(now) {
|
||||
// Use next_due_date for categorization (this handles recurring tasks properly)
|
||||
if task.NextDueDate != nil {
|
||||
if task.NextDueDate.Before(now) {
|
||||
overdue = append(overdue, task)
|
||||
} else if task.DueDate.Before(threshold) {
|
||||
} else if task.NextDueDate.Before(threshold) {
|
||||
dueSoon = append(dueSoon, task)
|
||||
} else {
|
||||
upcoming = append(upcoming, task)
|
||||
}
|
||||
} else {
|
||||
upcoming = append(upcoming, task)
|
||||
// No next_due_date and no completions - use due_date for initial categorization
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,8 +384,8 @@ func TestKanbanBoard_CompletedTasksGoToCompletedColumn(t *testing.T) {
|
||||
assert.Len(t, completedColumn.Tasks, 1)
|
||||
assert.Equal(t, "Completed Task", completedColumn.Tasks[0].Title)
|
||||
|
||||
// Verify button types for completed column (view only)
|
||||
assert.ElementsMatch(t, []string{"view"}, completedColumn.ButtonTypes)
|
||||
// Verify button types for completed column (read-only, no buttons)
|
||||
assert.ElementsMatch(t, []string{}, completedColumn.ButtonTypes)
|
||||
}
|
||||
|
||||
func TestKanbanBoard_InProgressTasksGoToInProgressColumn(t *testing.T) {
|
||||
@@ -773,7 +773,7 @@ func TestKanbanBoard_ColumnMetadata(t *testing.T) {
|
||||
{"in_progress_tasks", "In Progress", "#5856D6", []string{"edit", "complete", "cancel"}, "hammer", "Build"},
|
||||
{"due_soon_tasks", "Due Soon", "#FF9500", []string{"edit", "complete", "cancel", "mark_in_progress"}, "clock", "Schedule"},
|
||||
{"upcoming_tasks", "Upcoming", "#007AFF", []string{"edit", "complete", "cancel", "mark_in_progress"}, "calendar", "Event"},
|
||||
{"completed_tasks", "Completed", "#34C759", []string{"view"}, "checkmark.circle", "CheckCircle"},
|
||||
{"completed_tasks", "Completed", "#34C759", []string{}, "checkmark.circle", "CheckCircle"}, // Completed tasks are read-only (no buttons)
|
||||
{"cancelled_tasks", "Cancelled", "#8E8E93", []string{"uncancel", "delete"}, "xmark.circle", "Cancel"},
|
||||
}
|
||||
|
||||
|
||||
@@ -437,3 +437,90 @@ type RegisterDeviceRequest struct {
|
||||
RegistrationID string `json:"registration_id" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required,oneof=ios android"`
|
||||
}
|
||||
|
||||
// === Task Notifications with Actions ===
|
||||
|
||||
// CreateAndSendTaskNotification creates and sends a task notification with actionable buttons
|
||||
// The backend always sends full notification data - the client decides how to display
|
||||
// based on its locally cached subscription status
|
||||
func (s *NotificationService) CreateAndSendTaskNotification(
|
||||
ctx context.Context,
|
||||
userID uint,
|
||||
notificationType models.NotificationType,
|
||||
task *models.Task,
|
||||
) error {
|
||||
// Check user notification preferences
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.isNotificationEnabled(prefs, notificationType) {
|
||||
return nil // Skip silently
|
||||
}
|
||||
|
||||
// Build notification content - always send full data
|
||||
title := GetTaskNotificationTitle(notificationType)
|
||||
body := task.Title
|
||||
|
||||
// Get button types and iOS category based on task state
|
||||
buttonTypes := GetButtonTypesForTask(task, 30) // 30 days threshold
|
||||
iosCategoryID := GetIOSCategoryForTask(task)
|
||||
|
||||
// Build data payload - always includes full task info
|
||||
// Client decides what to display based on local subscription status
|
||||
data := map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"task_name": task.Title,
|
||||
"residence_id": task.ResidenceID,
|
||||
"type": string(notificationType),
|
||||
"button_types": buttonTypes,
|
||||
"ios_category": iosCategoryID,
|
||||
}
|
||||
|
||||
// Create notification record
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
notification := &models.Notification{
|
||||
UserID: userID,
|
||||
NotificationType: notificationType,
|
||||
Title: title,
|
||||
Body: body,
|
||||
Data: string(dataJSON),
|
||||
TaskID: &task.ID,
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.Create(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get device tokens
|
||||
iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert data for push payload
|
||||
pushData := make(map[string]string)
|
||||
for k, v := range data {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
pushData[k] = val
|
||||
case uint:
|
||||
pushData[k] = strconv.FormatUint(uint64(val), 10)
|
||||
default:
|
||||
jsonVal, _ := json.Marshal(val)
|
||||
pushData[k] = string(jsonVal)
|
||||
}
|
||||
}
|
||||
pushData["notification_id"] = strconv.FormatUint(uint64(notification.ID), 10)
|
||||
|
||||
// Send push notification with actionable support
|
||||
if s.pushClient != nil {
|
||||
err = s.pushClient.SendActionableNotification(ctx, iosTokens, androidTokens, title, body, pushData, iosCategoryID)
|
||||
if err != nil {
|
||||
s.notificationRepo.SetError(notification.ID, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.notificationRepo.MarkAsSent(notification.ID)
|
||||
}
|
||||
|
||||
96
internal/services/task_button_types.go
Normal file
96
internal/services/task_button_types.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// iOS Notification Category Identifiers
|
||||
const (
|
||||
IOSCategoryTaskActionable = "TASK_ACTIONABLE" // overdue, due_soon, upcoming
|
||||
IOSCategoryTaskInProgress = "TASK_IN_PROGRESS" // tasks in progress
|
||||
IOSCategoryTaskCancelled = "TASK_CANCELLED" // cancelled tasks
|
||||
IOSCategoryTaskCompleted = "TASK_COMPLETED" // completed tasks (read-only)
|
||||
IOSCategoryTaskGeneric = "TASK_NOTIFICATION_GENERIC" // non-premium users
|
||||
)
|
||||
|
||||
// GetButtonTypesForTask returns the appropriate button_types for a task
|
||||
// This reuses the same categorization logic as GetKanbanData in task_repo.go
|
||||
func GetButtonTypesForTask(task *models.Task, daysThreshold int) []string {
|
||||
now := time.Now().UTC()
|
||||
threshold := now.AddDate(0, 0, daysThreshold)
|
||||
|
||||
// Priority order matches kanban logic
|
||||
if task.IsCancelled {
|
||||
return []string{"uncancel", "delete"}
|
||||
}
|
||||
|
||||
// Check if task is "completed" (one-time task with nil next_due_date)
|
||||
if isTaskCompleted(task) {
|
||||
return []string{} // read-only
|
||||
}
|
||||
|
||||
if task.Status != nil && task.Status.Name == "In Progress" {
|
||||
return []string{"edit", "complete", "cancel"}
|
||||
}
|
||||
|
||||
// Use next_due_date for categorization (handles recurring tasks properly)
|
||||
if task.NextDueDate != nil {
|
||||
if task.NextDueDate.Before(now) {
|
||||
// Overdue
|
||||
return []string{"edit", "complete", "cancel", "mark_in_progress"}
|
||||
} else if task.NextDueDate.Before(threshold) {
|
||||
// Due Soon
|
||||
return []string{"edit", "complete", "cancel", "mark_in_progress"}
|
||||
}
|
||||
} else if task.DueDate != nil {
|
||||
// Fallback to due_date if next_due_date not set yet
|
||||
if task.DueDate.Before(now) {
|
||||
return []string{"edit", "complete", "cancel", "mark_in_progress"}
|
||||
} else if task.DueDate.Before(threshold) {
|
||||
return []string{"edit", "complete", "cancel", "mark_in_progress"}
|
||||
}
|
||||
}
|
||||
|
||||
// Upcoming (default for tasks with future due dates or no due date)
|
||||
return []string{"edit", "complete", "cancel", "mark_in_progress"}
|
||||
}
|
||||
|
||||
// isTaskCompleted determines if a task should be considered "completed" for kanban display.
|
||||
// A task is completed if next_due_date is nil (meaning it was a one-time task that's been completed).
|
||||
// Recurring tasks always have a next_due_date after completion, so they're never "completed" permanently.
|
||||
func isTaskCompleted(task *models.Task) bool {
|
||||
// If next_due_date is nil and task has completions, it's a completed one-time task
|
||||
return task.NextDueDate == nil && len(task.Completions) > 0
|
||||
}
|
||||
|
||||
// GetIOSCategoryForTask returns the iOS notification category identifier
|
||||
func GetIOSCategoryForTask(task *models.Task) string {
|
||||
if task.IsCancelled {
|
||||
return IOSCategoryTaskCancelled
|
||||
}
|
||||
if isTaskCompleted(task) {
|
||||
return IOSCategoryTaskCompleted
|
||||
}
|
||||
if task.Status != nil && task.Status.Name == "In Progress" {
|
||||
return IOSCategoryTaskInProgress
|
||||
}
|
||||
return IOSCategoryTaskActionable
|
||||
}
|
||||
|
||||
// GetTaskNotificationTitle returns the notification title for a task notification type
|
||||
func GetTaskNotificationTitle(notificationType models.NotificationType) string {
|
||||
switch notificationType {
|
||||
case models.NotificationTaskDueSoon:
|
||||
return "Task Due Soon"
|
||||
case models.NotificationTaskOverdue:
|
||||
return "Task Overdue"
|
||||
case models.NotificationTaskCompleted:
|
||||
return "Task Completed"
|
||||
case models.NotificationTaskAssigned:
|
||||
return "Task Assigned"
|
||||
default:
|
||||
return "Task Update"
|
||||
}
|
||||
}
|
||||
1094
internal/services/task_categorization_test.go
Normal file
1094
internal/services/task_categorization_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -142,6 +141,7 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
|
||||
return nil, ErrResidenceAccessDenied
|
||||
}
|
||||
|
||||
dueDate := req.DueDate.ToTimePtr()
|
||||
task := &models.Task{
|
||||
ResidenceID: req.ResidenceID,
|
||||
CreatedByID: userID,
|
||||
@@ -152,7 +152,8 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
|
||||
StatusID: req.StatusID,
|
||||
FrequencyID: req.FrequencyID,
|
||||
AssignedToID: req.AssignedToID,
|
||||
DueDate: req.DueDate.ToTimePtr(),
|
||||
DueDate: dueDate,
|
||||
NextDueDate: dueDate, // Initialize next_due_date to due_date
|
||||
EstimatedCost: req.EstimatedCost,
|
||||
ContractorID: req.ContractorID,
|
||||
}
|
||||
@@ -213,7 +214,13 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
|
||||
task.AssignedToID = req.AssignedToID
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
task.DueDate = req.DueDate.ToTimePtr()
|
||||
newDueDate := req.DueDate.ToTimePtr()
|
||||
task.DueDate = newDueDate
|
||||
// Also update NextDueDate if the task doesn't have completions yet
|
||||
// (if it has completions, NextDueDate should be managed by completion logic)
|
||||
if len(task.Completions) == 0 {
|
||||
task.NextDueDate = newDueDate
|
||||
}
|
||||
}
|
||||
if req.EstimatedCost != nil {
|
||||
task.EstimatedCost = req.EstimatedCost
|
||||
@@ -482,6 +489,27 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update next_due_date and status based on frequency
|
||||
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil
|
||||
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
|
||||
// and reset status to "Pending" so task shows in correct kanban column
|
||||
if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 {
|
||||
// One-time task - clear next_due_date since it's completed
|
||||
task.NextDueDate = nil
|
||||
} else {
|
||||
// Recurring task - calculate next due date from completion date + frequency
|
||||
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
|
||||
task.NextDueDate = &nextDue
|
||||
|
||||
// Reset status to "Pending" (ID=1) so task appears in upcoming/due_soon
|
||||
// instead of staying in "In Progress" column
|
||||
pendingStatusID := uint(1)
|
||||
task.StatusID = &pendingStatusID
|
||||
}
|
||||
if err := s.taskRepo.Update(task); err != nil {
|
||||
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
|
||||
}
|
||||
|
||||
// Create images if provided
|
||||
for _, imageURL := range req.ImageURLs {
|
||||
if imageURL != "" {
|
||||
@@ -539,15 +567,6 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
|
||||
completedByName = completion.CompletedBy.GetFullName()
|
||||
}
|
||||
|
||||
title := "Task Completed"
|
||||
body := fmt.Sprintf("%s completed: %s", completedByName, task.Title)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"residence_id": task.ResidenceID,
|
||||
"completion_id": completion.ID,
|
||||
}
|
||||
|
||||
// Notify all users
|
||||
for _, user := range users {
|
||||
isCompleter := user.ID == completion.CompletedByID
|
||||
@@ -556,13 +575,11 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
|
||||
if !isCompleter && s.notificationService != nil {
|
||||
go func(userID uint) {
|
||||
ctx := context.Background()
|
||||
if err := s.notificationService.CreateAndSendNotification(
|
||||
if err := s.notificationService.CreateAndSendTaskNotification(
|
||||
ctx,
|
||||
userID,
|
||||
models.NotificationTaskCompleted,
|
||||
title,
|
||||
body,
|
||||
data,
|
||||
task,
|
||||
); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
|
||||
}
|
||||
|
||||
@@ -324,6 +324,67 @@ func TestTaskService_CreateCompletion(t *testing.T) {
|
||||
assert.Equal(t, "Completed successfully", resp.Notes)
|
||||
}
|
||||
|
||||
func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
service := NewTaskService(taskRepo, residenceRepo)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
|
||||
// Get the "In Progress" status (ID=2) and a recurring frequency
|
||||
var inProgressStatus models.TaskStatus
|
||||
db.Where("name = ?", "In Progress").First(&inProgressStatus)
|
||||
|
||||
var monthlyFrequency models.TaskFrequency
|
||||
db.Where("name = ?", "Monthly").First(&monthlyFrequency)
|
||||
|
||||
// Create a recurring task with "In Progress" status
|
||||
dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days
|
||||
task := &models.Task{
|
||||
ResidenceID: residence.ID,
|
||||
CreatedByID: user.ID,
|
||||
Title: "Recurring Task",
|
||||
StatusID: &inProgressStatus.ID,
|
||||
FrequencyID: &monthlyFrequency.ID,
|
||||
DueDate: &dueDate,
|
||||
NextDueDate: &dueDate,
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
}
|
||||
err := db.Create(task).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Complete the task
|
||||
req := &requests.CreateTaskCompletionRequest{
|
||||
TaskID: task.ID,
|
||||
Notes: "Monthly maintenance done",
|
||||
}
|
||||
|
||||
resp, err := service.CreateCompletion(req, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.ID)
|
||||
|
||||
// Verify the task in the response has status reset to "Pending" (ID=1)
|
||||
require.NotNil(t, resp.Task, "Response should include the updated task")
|
||||
require.NotNil(t, resp.Task.StatusID, "Task should have a status ID")
|
||||
assert.Equal(t, uint(1), *resp.Task.StatusID, "Recurring task status should be reset to Pending (ID=1) after completion")
|
||||
|
||||
// Verify NextDueDate was updated (should be ~30 days from now for monthly)
|
||||
require.NotNil(t, resp.Task.NextDueDate, "Recurring task should have NextDueDate set")
|
||||
expectedNextDue := time.Now().AddDate(0, 0, 30) // Monthly = 30 days
|
||||
assert.WithinDuration(t, expectedNextDue, *resp.Task.NextDueDate, 24*time.Hour, "NextDueDate should be approximately 30 days from now")
|
||||
|
||||
// Also verify by reloading from database directly
|
||||
var reloadedTask models.Task
|
||||
db.Preload("Status").First(&reloadedTask, task.ID)
|
||||
require.NotNil(t, reloadedTask.StatusID)
|
||||
assert.Equal(t, uint(1), *reloadedTask.StatusID, "Database should show Pending status")
|
||||
assert.Equal(t, "Pending", reloadedTask.Status.Name)
|
||||
}
|
||||
|
||||
func TestTaskService_GetCompletion(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
|
||||
259
internal/task/categorization/chain.go
Normal file
259
internal/task/categorization/chain.go
Normal file
@@ -0,0 +1,259 @@
|
||||
// Package categorization implements the Chain of Responsibility pattern for
|
||||
// determining which kanban column a task belongs to.
|
||||
//
|
||||
// The chain evaluates tasks in a specific priority order, with each handler
|
||||
// checking if the task matches its criteria. If a handler matches, it returns
|
||||
// the column name; otherwise, it passes to the next handler in the chain.
|
||||
package categorization
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// KanbanColumn represents the possible kanban column names
|
||||
type KanbanColumn string
|
||||
|
||||
const (
|
||||
ColumnOverdue KanbanColumn = "overdue_tasks"
|
||||
ColumnDueSoon KanbanColumn = "due_soon_tasks"
|
||||
ColumnUpcoming KanbanColumn = "upcoming_tasks"
|
||||
ColumnInProgress KanbanColumn = "in_progress_tasks"
|
||||
ColumnCompleted KanbanColumn = "completed_tasks"
|
||||
ColumnCancelled KanbanColumn = "cancelled_tasks"
|
||||
)
|
||||
|
||||
// String returns the string representation of the column
|
||||
func (c KanbanColumn) String() string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
// Context holds the data needed to categorize a task
|
||||
type Context struct {
|
||||
Task *models.Task
|
||||
Now time.Time
|
||||
DaysThreshold int
|
||||
}
|
||||
|
||||
// NewContext creates a new categorization context with sensible defaults
|
||||
func NewContext(task *models.Task, daysThreshold int) *Context {
|
||||
if daysThreshold <= 0 {
|
||||
daysThreshold = 30
|
||||
}
|
||||
return &Context{
|
||||
Task: task,
|
||||
Now: time.Now().UTC(),
|
||||
DaysThreshold: daysThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdDate returns the date threshold for "due soon" categorization
|
||||
func (c *Context) ThresholdDate() time.Time {
|
||||
return c.Now.AddDate(0, 0, c.DaysThreshold)
|
||||
}
|
||||
|
||||
// Handler defines the interface for task categorization handlers
|
||||
type Handler interface {
|
||||
// SetNext sets the next handler in the chain
|
||||
SetNext(handler Handler) Handler
|
||||
|
||||
// Handle processes the task and returns the column name if matched,
|
||||
// or delegates to the next handler
|
||||
Handle(ctx *Context) KanbanColumn
|
||||
}
|
||||
|
||||
// BaseHandler provides default chaining behavior
|
||||
type BaseHandler struct {
|
||||
next Handler
|
||||
}
|
||||
|
||||
// SetNext sets the next handler and returns it for fluent chaining
|
||||
func (h *BaseHandler) SetNext(handler Handler) Handler {
|
||||
h.next = handler
|
||||
return handler
|
||||
}
|
||||
|
||||
// HandleNext delegates to the next handler or returns default
|
||||
func (h *BaseHandler) HandleNext(ctx *Context) KanbanColumn {
|
||||
if h.next != nil {
|
||||
return h.next.Handle(ctx)
|
||||
}
|
||||
return ColumnUpcoming // Default fallback
|
||||
}
|
||||
|
||||
// === Concrete Handlers ===
|
||||
|
||||
// CancelledHandler checks if the task is cancelled
|
||||
// Priority: 1 (highest - checked first)
|
||||
type CancelledHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
|
||||
if ctx.Task.IsCancelled {
|
||||
return ColumnCancelled
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// CompletedHandler checks if the task is completed (one-time task with completions and no next due date)
|
||||
// Priority: 2
|
||||
type CompletedHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// A task is completed if:
|
||||
// - It has at least one completion record
|
||||
// - AND it has no NextDueDate (meaning it's a one-time task or the cycle is done)
|
||||
if ctx.Task.NextDueDate == nil && len(ctx.Task.Completions) > 0 {
|
||||
return ColumnCompleted
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// InProgressHandler checks if the task status is "In Progress"
|
||||
// Priority: 3
|
||||
type InProgressHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
|
||||
if ctx.Task.Status != nil && ctx.Task.Status.Name == "In Progress" {
|
||||
return ColumnInProgress
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// OverdueHandler checks if the task is overdue based on NextDueDate or DueDate
|
||||
// Priority: 4
|
||||
type OverdueHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
|
||||
effectiveDate := h.getEffectiveDate(ctx.Task)
|
||||
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
|
||||
return ColumnOverdue
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
func (h *OverdueHandler) getEffectiveDate(task *models.Task) *time.Time {
|
||||
// Prefer NextDueDate for recurring tasks
|
||||
if task.NextDueDate != nil {
|
||||
return task.NextDueDate
|
||||
}
|
||||
// Fall back to DueDate for initial categorization
|
||||
return task.DueDate
|
||||
}
|
||||
|
||||
// DueSoonHandler checks if the task is due within the threshold period
|
||||
// Priority: 5
|
||||
type DueSoonHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
|
||||
effectiveDate := h.getEffectiveDate(ctx.Task)
|
||||
threshold := ctx.ThresholdDate()
|
||||
|
||||
if effectiveDate != nil && effectiveDate.Before(threshold) {
|
||||
return ColumnDueSoon
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
func (h *DueSoonHandler) getEffectiveDate(task *models.Task) *time.Time {
|
||||
if task.NextDueDate != nil {
|
||||
return task.NextDueDate
|
||||
}
|
||||
return task.DueDate
|
||||
}
|
||||
|
||||
// UpcomingHandler is the final handler that catches all remaining tasks
|
||||
// Priority: 6 (lowest - default)
|
||||
type UpcomingHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *UpcomingHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// This is the default catch-all
|
||||
return ColumnUpcoming
|
||||
}
|
||||
|
||||
// === Chain Builder ===
|
||||
|
||||
// Chain manages the categorization chain
|
||||
type Chain struct {
|
||||
head Handler
|
||||
}
|
||||
|
||||
// NewChain creates a new categorization chain with handlers in priority order
|
||||
func NewChain() *Chain {
|
||||
// Build the chain in priority order (first handler has highest priority)
|
||||
cancelled := &CancelledHandler{}
|
||||
completed := &CompletedHandler{}
|
||||
inProgress := &InProgressHandler{}
|
||||
overdue := &OverdueHandler{}
|
||||
dueSoon := &DueSoonHandler{}
|
||||
upcoming := &UpcomingHandler{}
|
||||
|
||||
// Chain them together: cancelled -> completed -> inProgress -> overdue -> dueSoon -> upcoming
|
||||
cancelled.SetNext(completed).
|
||||
SetNext(inProgress).
|
||||
SetNext(overdue).
|
||||
SetNext(dueSoon).
|
||||
SetNext(upcoming)
|
||||
|
||||
return &Chain{head: cancelled}
|
||||
}
|
||||
|
||||
// Categorize determines which kanban column a task belongs to
|
||||
func (c *Chain) Categorize(task *models.Task, daysThreshold int) KanbanColumn {
|
||||
ctx := NewContext(task, daysThreshold)
|
||||
return c.head.Handle(ctx)
|
||||
}
|
||||
|
||||
// CategorizeWithContext uses a pre-built context for categorization
|
||||
func (c *Chain) CategorizeWithContext(ctx *Context) KanbanColumn {
|
||||
return c.head.Handle(ctx)
|
||||
}
|
||||
|
||||
// === Convenience Functions ===
|
||||
|
||||
// defaultChain is a singleton chain instance for convenience
|
||||
var defaultChain = NewChain()
|
||||
|
||||
// DetermineKanbanColumn is a convenience function that uses the default chain
|
||||
func DetermineKanbanColumn(task *models.Task, daysThreshold int) string {
|
||||
return defaultChain.Categorize(task, daysThreshold).String()
|
||||
}
|
||||
|
||||
// CategorizeTask is an alias for DetermineKanbanColumn with a more descriptive name
|
||||
func CategorizeTask(task *models.Task, daysThreshold int) KanbanColumn {
|
||||
return defaultChain.Categorize(task, daysThreshold)
|
||||
}
|
||||
|
||||
// CategorizeTasksIntoColumns categorizes multiple tasks into their respective columns
|
||||
func CategorizeTasksIntoColumns(tasks []models.Task, daysThreshold int) map[KanbanColumn][]models.Task {
|
||||
result := make(map[KanbanColumn][]models.Task)
|
||||
|
||||
// Initialize all columns with empty slices
|
||||
for _, col := range []KanbanColumn{
|
||||
ColumnOverdue, ColumnDueSoon, ColumnUpcoming,
|
||||
ColumnInProgress, ColumnCompleted, ColumnCancelled,
|
||||
} {
|
||||
result[col] = make([]models.Task, 0)
|
||||
}
|
||||
|
||||
// Categorize each task
|
||||
chain := NewChain()
|
||||
for _, task := range tasks {
|
||||
column := chain.Categorize(&task, daysThreshold)
|
||||
result[column] = append(result[column], task)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
375
internal/task/categorization/chain_test.go
Normal file
375
internal/task/categorization/chain_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package categorization
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// Helper to create a time pointer
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
// Helper to create a uint pointer
|
||||
func uintPtr(v uint) *uint {
|
||||
return &v
|
||||
}
|
||||
|
||||
// Helper to create a completion with an ID
|
||||
func makeCompletion(id uint) models.TaskCompletion {
|
||||
c := models.TaskCompletion{CompletedAt: time.Now()}
|
||||
c.ID = id
|
||||
return c
|
||||
}
|
||||
|
||||
// Helper to create a task with an ID
|
||||
func makeTask(id uint) models.Task {
|
||||
t := models.Task{}
|
||||
t.ID = id
|
||||
return t
|
||||
}
|
||||
|
||||
func TestCancelledHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("cancelled task goes to cancelled column", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
IsCancelled: true,
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCancelled, result)
|
||||
})
|
||||
|
||||
t.Run("cancelled task with due date still goes to cancelled", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, -10) // 10 days ago (overdue)
|
||||
task := &models.Task{
|
||||
IsCancelled: true,
|
||||
DueDate: &dueDate,
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCancelled, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompletedHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("one-time task with completion and no next_due_date goes to completed", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCompleted, result)
|
||||
})
|
||||
|
||||
t.Run("recurring task with completion but has next_due_date does NOT go to completed", func(t *testing.T) {
|
||||
nextDue := time.Now().AddDate(0, 0, 30)
|
||||
task := &models.Task{
|
||||
NextDueDate: &nextDue,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
// Should go to due_soon or upcoming, not completed
|
||||
assert.NotEqual(t, ColumnCompleted, result)
|
||||
})
|
||||
|
||||
t.Run("task with no completions does not go to completed", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.NotEqual(t, ColumnCompleted, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInProgressHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task with In Progress status goes to in_progress column", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnInProgress, result)
|
||||
})
|
||||
|
||||
t.Run("task with Pending status does not go to in_progress", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.NotEqual(t, ColumnInProgress, result)
|
||||
})
|
||||
|
||||
t.Run("task with nil status does not go to in_progress", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
Status: nil,
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.NotEqual(t, ColumnInProgress, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOverdueHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task with past next_due_date goes to overdue", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago
|
||||
task := &models.Task{
|
||||
NextDueDate: &pastDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
|
||||
t.Run("task with past due_date (no next_due_date) goes to overdue", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -5) // 5 days ago
|
||||
task := &models.Task{
|
||||
DueDate: &pastDate,
|
||||
NextDueDate: nil,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
|
||||
t.Run("next_due_date takes precedence over due_date", func(t *testing.T) {
|
||||
pastDueDate := time.Now().AddDate(0, 0, -10) // 10 days ago
|
||||
futureNextDue := time.Now().AddDate(0, 0, 60) // 60 days from now
|
||||
task := &models.Task{
|
||||
DueDate: &pastDueDate,
|
||||
NextDueDate: &futureNextDue,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
// Should be upcoming (60 days > 30 day threshold), not overdue
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDueSoonHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task due within threshold goes to due_soon", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, 15) // 15 days from now
|
||||
task := &models.Task{
|
||||
NextDueDate: &dueDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30) // 30 day threshold
|
||||
assert.Equal(t, ColumnDueSoon, result)
|
||||
})
|
||||
|
||||
t.Run("task due exactly at threshold goes to due_soon", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, 29) // Just under 30 days
|
||||
task := &models.Task{
|
||||
NextDueDate: &dueDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnDueSoon, result)
|
||||
})
|
||||
|
||||
t.Run("custom threshold is respected", func(t *testing.T) {
|
||||
dueDate := time.Now().AddDate(0, 0, 10) // 10 days from now
|
||||
task := &models.Task{
|
||||
NextDueDate: &dueDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
// With 7 day threshold, 10 days out should be upcoming, not due_soon
|
||||
result := chain.Categorize(task, 7)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpcomingHandler(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("task with future next_due_date beyond threshold goes to upcoming", func(t *testing.T) {
|
||||
futureDate := time.Now().AddDate(0, 0, 60) // 60 days from now
|
||||
task := &models.Task{
|
||||
NextDueDate: &futureDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
|
||||
t.Run("task with no due date goes to upcoming (default)", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
DueDate: nil,
|
||||
NextDueDate: nil,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChainPriorityOrder(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("cancelled takes priority over everything", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -10)
|
||||
task := &models.Task{
|
||||
IsCancelled: true,
|
||||
DueDate: &pastDate,
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCancelled, result)
|
||||
})
|
||||
|
||||
t.Run("completed takes priority over in_progress", func(t *testing.T) {
|
||||
task := &models.Task{
|
||||
IsCancelled: false,
|
||||
NextDueDate: nil,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnCompleted, result)
|
||||
})
|
||||
|
||||
t.Run("in_progress takes priority over overdue", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -10)
|
||||
task := &models.Task{
|
||||
IsCancelled: false,
|
||||
NextDueDate: &pastDate,
|
||||
Status: &models.TaskStatus{Name: "In Progress"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnInProgress, result)
|
||||
})
|
||||
|
||||
t.Run("overdue takes priority over due_soon", func(t *testing.T) {
|
||||
pastDate := time.Now().AddDate(0, 0, -1)
|
||||
task := &models.Task{
|
||||
IsCancelled: false,
|
||||
NextDueDate: &pastDate,
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecurringTaskScenarios(t *testing.T) {
|
||||
chain := NewChain()
|
||||
|
||||
t.Run("annual task just completed should go to upcoming (next_due_date is 1 year out)", func(t *testing.T) {
|
||||
nextYear := time.Now().AddDate(1, 0, 0)
|
||||
task := &models.Task{
|
||||
NextDueDate: &nextYear,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "Pending"}, // Reset after completion
|
||||
Frequency: &models.TaskFrequency{Name: "Annually", Days: intPtr(365)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnUpcoming, result)
|
||||
})
|
||||
|
||||
t.Run("monthly task due in 2 weeks should go to due_soon", func(t *testing.T) {
|
||||
twoWeeks := time.Now().AddDate(0, 0, 14)
|
||||
task := &models.Task{
|
||||
NextDueDate: &twoWeeks,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
Frequency: &models.TaskFrequency{Name: "Monthly", Days: intPtr(30)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnDueSoon, result)
|
||||
})
|
||||
|
||||
t.Run("weekly task that is overdue should go to overdue", func(t *testing.T) {
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
task := &models.Task{
|
||||
NextDueDate: &yesterday,
|
||||
Completions: []models.TaskCompletion{makeCompletion(1)},
|
||||
Status: &models.TaskStatus{Name: "Pending"},
|
||||
Frequency: &models.TaskFrequency{Name: "Weekly", Days: intPtr(7)},
|
||||
}
|
||||
result := chain.Categorize(task, 30)
|
||||
assert.Equal(t, ColumnOverdue, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCategorizeTasksIntoColumns(t *testing.T) {
|
||||
now := time.Now()
|
||||
pastDate := now.AddDate(0, 0, -5)
|
||||
soonDate := now.AddDate(0, 0, 15)
|
||||
futureDate := now.AddDate(0, 0, 60)
|
||||
|
||||
// Create tasks with proper IDs
|
||||
task1 := makeTask(1)
|
||||
task1.IsCancelled = true
|
||||
|
||||
task2 := makeTask(2)
|
||||
task2.NextDueDate = nil
|
||||
task2.Completions = []models.TaskCompletion{makeCompletion(1)}
|
||||
|
||||
task3 := makeTask(3)
|
||||
task3.Status = &models.TaskStatus{Name: "In Progress"}
|
||||
|
||||
task4 := makeTask(4)
|
||||
task4.NextDueDate = &pastDate
|
||||
task4.Status = &models.TaskStatus{Name: "Pending"}
|
||||
|
||||
task5 := makeTask(5)
|
||||
task5.NextDueDate = &soonDate
|
||||
task5.Status = &models.TaskStatus{Name: "Pending"}
|
||||
|
||||
task6 := makeTask(6)
|
||||
task6.NextDueDate = &futureDate
|
||||
task6.Status = &models.TaskStatus{Name: "Pending"}
|
||||
|
||||
tasks := []models.Task{task1, task2, task3, task4, task5, task6}
|
||||
|
||||
result := CategorizeTasksIntoColumns(tasks, 30)
|
||||
|
||||
assert.Len(t, result[ColumnCancelled], 1)
|
||||
assert.Equal(t, uint(1), result[ColumnCancelled][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnCompleted], 1)
|
||||
assert.Equal(t, uint(2), result[ColumnCompleted][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnInProgress], 1)
|
||||
assert.Equal(t, uint(3), result[ColumnInProgress][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnOverdue], 1)
|
||||
assert.Equal(t, uint(4), result[ColumnOverdue][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnDueSoon], 1)
|
||||
assert.Equal(t, uint(5), result[ColumnDueSoon][0].ID)
|
||||
|
||||
assert.Len(t, result[ColumnUpcoming], 1)
|
||||
assert.Equal(t, uint(6), result[ColumnUpcoming][0].ID)
|
||||
}
|
||||
|
||||
func TestDefaultThreshold(t *testing.T) {
|
||||
task := &models.Task{}
|
||||
|
||||
// Test that 0 or negative threshold defaults to 30
|
||||
ctx1 := NewContext(task, 0)
|
||||
assert.Equal(t, 30, ctx1.DaysThreshold)
|
||||
|
||||
ctx2 := NewContext(task, -5)
|
||||
assert.Equal(t, 30, ctx2.DaysThreshold)
|
||||
|
||||
ctx3 := NewContext(task, 14)
|
||||
assert.Equal(t, 14, ctx3.DaysThreshold)
|
||||
}
|
||||
|
||||
// Helper to create int pointer
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
||||
t.id as task_id,
|
||||
t.title as task_title,
|
||||
t.due_date,
|
||||
EXTRACT(DAY FROM ? - t.due_date)::int as days_overdue,
|
||||
EXTRACT(DAY FROM ?::timestamp - t.due_date)::int as days_overdue,
|
||||
COALESCE(t.assigned_to_id, r.owner_id) as user_id,
|
||||
r.name as residence_name
|
||||
FROM task_task t
|
||||
|
||||
Reference in New Issue
Block a user