Files
honeyDueAPI/internal/worker/jobs/handler.go
Trey t 1b06c0639c 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>
2025-12-05 14:23:14 -06:00

499 lines
15 KiB
Go

package jobs
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/services"
)
// Task types
const (
TypeTaskReminder = "notification:task_reminder"
TypeOverdueReminder = "notification:overdue_reminder"
TypeDailyDigest = "notification:daily_digest"
TypeSendEmail = "email:send"
TypeSendPush = "push:send"
)
// Handler handles background job processing
type Handler struct {
db *gorm.DB
pushClient *push.Client
emailService *services.EmailService
config *config.Config
}
// NewHandler creates a new job handler
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, cfg *config.Config) *Handler {
return &Handler{
db: db,
pushClient: pushClient,
emailService: emailService,
config: cfg,
}
}
// TaskReminderData represents a task due soon for reminder notifications
type TaskReminderData struct {
TaskID uint
TaskTitle string
DueDate time.Time
UserID uint
UserEmail string
UserName string
ResidenceName string
}
// HandleTaskReminder processes task reminder notifications for tasks due today or tomorrow
func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing task reminder notifications...")
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
tomorrow := today.AddDate(0, 0, 1)
dayAfterTomorrow := today.AddDate(0, 0, 2)
// Query tasks due today or tomorrow that are not completed, cancelled, or archived
var tasks []struct {
TaskID uint
TaskTitle string
DueDate time.Time
UserID uint
ResidenceName string
}
err := h.db.Raw(`
SELECT DISTINCT
t.id as task_id,
t.title as task_title,
t.due_date,
COALESCE(t.assigned_to_id, r.owner_id) as user_id,
r.name as residence_name
FROM task_task t
JOIN residence_residence r ON t.residence_id = r.id
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
WHERE t.due_date >= ? AND t.due_date < ?
AND t.is_cancelled = false
AND t.is_archived = false
AND tc.id IS NULL
`, today, dayAfterTomorrow).Scan(&tasks).Error
if err != nil {
log.Error().Err(err).Msg("Failed to query tasks due soon")
return err
}
log.Info().Int("count", len(tasks)).Msg("Found tasks due today/tomorrow")
// Group by user and check preferences
userTasks := make(map[uint][]struct {
TaskID uint
TaskTitle string
DueDate time.Time
ResidenceName string
})
for _, t := range tasks {
userTasks[t.UserID] = append(userTasks[t.UserID], struct {
TaskID uint
TaskTitle string
DueDate time.Time
ResidenceName string
}{t.TaskID, t.TaskTitle, t.DueDate, t.ResidenceName})
}
// Send notifications to each user
for userID, userTaskList := range userTasks {
// Check user notification preferences
var prefs models.NotificationPreference
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
if err != nil && err != gorm.ErrRecordNotFound {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences")
continue
}
// Skip if user has disabled task due soon notifications
if err == nil && !prefs.TaskDueSoon {
log.Debug().Uint("user_id", userID).Msg("User has disabled task due soon notifications")
continue
}
// Build notification message
var title, body string
if len(userTaskList) == 1 {
t := userTaskList[0]
dueText := "today"
if t.DueDate.After(tomorrow) {
dueText = "tomorrow"
}
title = fmt.Sprintf("Task Due %s", dueText)
body = fmt.Sprintf("%s at %s is due %s", t.TaskTitle, t.ResidenceName, dueText)
} else {
todayCount := 0
tomorrowCount := 0
for _, t := range userTaskList {
if t.DueDate.Before(tomorrow) {
todayCount++
} else {
tomorrowCount++
}
}
title = "Tasks Due Soon"
body = fmt.Sprintf("You have %d task(s) due today and %d task(s) due tomorrow", todayCount, tomorrowCount)
}
// Send push notification
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
"type": "task_reminder",
}); err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder push")
}
// Create in-app notification record
for _, t := range userTaskList {
notification := &models.Notification{
UserID: userID,
NotificationType: models.NotificationTaskDueSoon,
Title: title,
Body: body,
TaskID: &t.TaskID,
}
if err := h.db.Create(notification).Error; err != nil {
log.Error().Err(err).Msg("Failed to create notification record")
}
}
}
log.Info().Msg("Task reminder notifications completed")
return nil
}
// HandleOverdueReminder processes overdue task notifications
func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing overdue task notifications...")
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
// Query overdue tasks that are not completed, cancelled, or archived
var tasks []struct {
TaskID uint
TaskTitle string
DueDate time.Time
DaysOverdue int
UserID uint
ResidenceName string
}
err := h.db.Raw(`
SELECT DISTINCT
t.id as task_id,
t.title as task_title,
t.due_date,
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
JOIN residence_residence r ON t.residence_id = r.id
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
WHERE t.due_date < ?
AND t.is_cancelled = false
AND t.is_archived = false
AND tc.id IS NULL
ORDER BY t.due_date ASC
`, today, today).Scan(&tasks).Error
if err != nil {
log.Error().Err(err).Msg("Failed to query overdue tasks")
return err
}
log.Info().Int("count", len(tasks)).Msg("Found overdue tasks")
// Group by user
userTasks := make(map[uint][]struct {
TaskID uint
TaskTitle string
DaysOverdue int
ResidenceName string
})
for _, t := range tasks {
userTasks[t.UserID] = append(userTasks[t.UserID], struct {
TaskID uint
TaskTitle string
DaysOverdue int
ResidenceName string
}{t.TaskID, t.TaskTitle, t.DaysOverdue, t.ResidenceName})
}
// Send notifications to each user
for userID, userTaskList := range userTasks {
// Check user notification preferences
var prefs models.NotificationPreference
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
if err != nil && err != gorm.ErrRecordNotFound {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences")
continue
}
// Skip if user has disabled overdue notifications
if err == nil && !prefs.TaskOverdue {
log.Debug().Uint("user_id", userID).Msg("User has disabled overdue task notifications")
continue
}
// Build notification message
var title, body string
if len(userTaskList) == 1 {
t := userTaskList[0]
title = "Overdue Task"
if t.DaysOverdue == 1 {
body = fmt.Sprintf("%s at %s is 1 day overdue", t.TaskTitle, t.ResidenceName)
} else {
body = fmt.Sprintf("%s at %s is %d days overdue", t.TaskTitle, t.ResidenceName, t.DaysOverdue)
}
} else {
title = "Overdue Tasks"
body = fmt.Sprintf("You have %d overdue tasks that need attention", len(userTaskList))
}
// Send push notification
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
"type": "overdue_reminder",
}); err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue reminder push")
}
// Create in-app notification record
notification := &models.Notification{
UserID: userID,
NotificationType: models.NotificationTaskOverdue,
Title: title,
Body: body,
}
if err := h.db.Create(notification).Error; err != nil {
log.Error().Err(err).Msg("Failed to create notification record")
}
}
log.Info().Msg("Overdue task notifications completed")
return nil
}
// HandleDailyDigest processes daily digest notifications with task statistics
func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing daily digest notifications...")
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
nextWeek := today.AddDate(0, 0, 7)
// Get all users with their task statistics
var userStats []struct {
UserID uint
TotalTasks int
OverdueTasks int
DueThisWeek int
}
err := h.db.Raw(`
SELECT
u.id as user_id,
COUNT(DISTINCT t.id) as total_tasks,
COUNT(DISTINCT CASE WHEN t.due_date < ? AND tc.id IS NULL THEN t.id END) as overdue_tasks,
COUNT(DISTINCT CASE WHEN t.due_date >= ? AND t.due_date < ? AND tc.id IS NULL THEN t.id END) as due_this_week
FROM user_user u
JOIN residence_residence r ON r.owner_id = u.id OR r.id IN (
SELECT residence_id FROM residence_residence_users WHERE user_id = u.id
)
JOIN task_task t ON t.residence_id = r.id
AND t.is_cancelled = false
AND t.is_archived = false
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
WHERE u.is_active = true
GROUP BY u.id
HAVING COUNT(DISTINCT t.id) > 0
`, today, today, nextWeek).Scan(&userStats).Error
if err != nil {
log.Error().Err(err).Msg("Failed to query user task statistics")
return err
}
log.Info().Int("users", len(userStats)).Msg("Processing daily digest for users")
for _, stats := range userStats {
// Skip users with no actionable items
if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 {
continue
}
// Build notification message
title := "Daily Task Summary"
var body string
if stats.OverdueTasks > 0 && stats.DueThisWeek > 0 {
body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", stats.OverdueTasks, stats.DueThisWeek)
} else if stats.OverdueTasks > 0 {
body = fmt.Sprintf("You have %d overdue task(s) that need attention", stats.OverdueTasks)
} else {
body = fmt.Sprintf("You have %d task(s) due this week", stats.DueThisWeek)
}
// Send push notification
if err := h.sendPushToUser(ctx, stats.UserID, title, body, map[string]string{
"type": "daily_digest",
"overdue": fmt.Sprintf("%d", stats.OverdueTasks),
"due_this_week": fmt.Sprintf("%d", stats.DueThisWeek),
}); err != nil {
log.Error().Err(err).Uint("user_id", stats.UserID).Msg("Failed to send daily digest push")
}
}
log.Info().Msg("Daily digest notifications completed")
return nil
}
// EmailPayload represents the payload for email tasks
type EmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
HTMLBody string `json:"html_body"`
TextBody string `json:"text_body"`
}
// HandleSendEmail processes email sending tasks
func (h *Handler) HandleSendEmail(ctx context.Context, task *asynq.Task) error {
var payload EmailPayload
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
return fmt.Errorf("failed to unmarshal payload: %w", err)
}
log.Info().
Str("to", payload.To).
Str("subject", payload.Subject).
Msg("Sending email...")
if h.emailService == nil {
log.Warn().Msg("Email service not configured, skipping email")
return nil
}
// Use the email service to send the email
if err := h.emailService.SendEmail(payload.To, payload.Subject, payload.HTMLBody, payload.TextBody); err != nil {
log.Error().Err(err).Str("to", payload.To).Msg("Failed to send email")
return err
}
log.Info().Str("to", payload.To).Msg("Email sent successfully")
return nil
}
// PushPayload represents the payload for push notification tasks
type PushPayload struct {
UserID uint `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Data map[string]string `json:"data,omitempty"`
}
// HandleSendPush processes push notification tasks
func (h *Handler) HandleSendPush(ctx context.Context, task *asynq.Task) error {
var payload PushPayload
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
return fmt.Errorf("failed to unmarshal payload: %w", err)
}
log.Info().
Uint("user_id", payload.UserID).
Str("title", payload.Title).
Msg("Sending push notification...")
if err := h.sendPushToUser(ctx, payload.UserID, payload.Title, payload.Message, payload.Data); err != nil {
log.Error().Err(err).Uint("user_id", payload.UserID).Msg("Failed to send push notification")
return err
}
log.Info().Uint("user_id", payload.UserID).Msg("Push notification sent successfully")
return nil
}
// sendPushToUser sends a push notification to all of a user's devices
func (h *Handler) sendPushToUser(ctx context.Context, userID uint, title, message string, data map[string]string) error {
if h.pushClient == nil {
log.Warn().Msg("Push client not configured, skipping notification")
return nil
}
// Get iOS device tokens
var iosTokens []string
err := h.db.Model(&models.APNSDevice{}).
Where("user_id = ? AND active = ?", userID, true).
Pluck("registration_id", &iosTokens).Error
if err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get iOS tokens")
}
// Get Android device tokens
var androidTokens []string
err = h.db.Model(&models.GCMDevice{}).
Where("user_id = ? AND active = ?", userID, true).
Pluck("registration_id", &androidTokens).Error
if err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get Android tokens")
}
if len(iosTokens) == 0 && len(androidTokens) == 0 {
log.Debug().Uint("user_id", userID).Msg("No device tokens found for user")
return nil
}
log.Debug().
Uint("user_id", userID).
Int("ios_tokens", len(iosTokens)).
Int("android_tokens", len(androidTokens)).
Msg("Sending push to user devices")
// Send to all devices
return h.pushClient.SendToAll(ctx, iosTokens, androidTokens, title, message, data)
}
// NewSendEmailTask creates a new email sending task
func NewSendEmailTask(to, subject, htmlBody, textBody string) (*asynq.Task, error) {
payload, err := json.Marshal(EmailPayload{
To: to,
Subject: subject,
HTMLBody: htmlBody,
TextBody: textBody,
})
if err != nil {
return nil, err
}
return asynq.NewTask(TypeSendEmail, payload), nil
}
// NewSendPushTask creates a new push notification task
func NewSendPushTask(userID uint, title, message string, data map[string]string) (*asynq.Task, error) {
payload, err := json.Marshal(PushPayload{
UserID: userID,
Title: title,
Message: message,
Data: data,
})
if err != nil {
return nil, err
}
return asynq.NewTask(TypeSendPush, payload), nil
}