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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user