Remove Gorush, use direct APNs/FCM, fix worker queries

- Remove Gorush push server dependency (now using direct APNs/FCM)
- Update docker-compose.yml to remove gorush service
- Update config.go to remove GORUSH_URL
- Fix worker queries:
  - Use auth_user instead of user_user table
  - Use completed_at instead of completion_date column
- Add NotificationService to worker handler for actionable notifications
- Add docs/PUSH_NOTIFICATIONS.md with architecture documentation
- Update README.md, DOKKU_SETUP.md, and dev.sh

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-06 00:59:42 -06:00
parent 91a1f7ebed
commit 5a6bad3ec3
8 changed files with 423 additions and 263 deletions

View File

@@ -60,10 +60,7 @@ type EmailConfig struct {
}
type PushConfig struct {
// Gorush server URL (deprecated - kept for backwards compatibility)
GorushURL string
// APNs (iOS)
// APNs (iOS) - uses github.com/sideshow/apns2 for direct APNs communication
APNSKeyPath string
APNSKeyID string
APNSTeamID string
@@ -71,7 +68,7 @@ type PushConfig struct {
APNSSandbox bool
APNSProduction bool // If true, use production APNs; if false, use sandbox
// FCM (Android)
// FCM (Android) - uses direct HTTP to FCM legacy API
FCMServerKey string
}
@@ -166,7 +163,6 @@ func Load() (*Config, error) {
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
},
Push: PushConfig{
GorushURL: viper.GetString("GORUSH_URL"),
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
@@ -244,7 +240,6 @@ func setDefaults() {
viper.SetDefault("DEFAULT_FROM_EMAIL", "Casera <noreply@casera.com>")
// Push notification defaults
viper.SetDefault("GORUSH_URL", "http://localhost:8088")
viper.SetDefault("APNS_TOPIC", "com.example.casera")
viper.SetDefault("APNS_USE_SANDBOX", true)
viper.SetDefault("APNS_PRODUCTION", false)

View File

@@ -27,19 +27,21 @@ const (
// Handler handles background job processing
type Handler struct {
db *gorm.DB
pushClient *push.Client
emailService *services.EmailService
config *config.Config
db *gorm.DB
pushClient *push.Client
emailService *services.EmailService
notificationService *services.NotificationService
config *config.Config
}
// NewHandler creates a new job handler
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, cfg *config.Config) *Handler {
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, notificationService *services.NotificationService, cfg *config.Config) *Handler {
return &Handler{
db: db,
pushClient: pushClient,
emailService: emailService,
config: cfg,
db: db,
pushClient: pushClient,
emailService: emailService,
notificationService: notificationService,
config: cfg,
}
}
@@ -54,66 +56,47 @@ type TaskReminderData struct {
ResidenceName string
}
// HandleTaskReminder processes task reminder notifications for tasks due today or tomorrow
// HandleTaskReminder processes task reminder notifications for tasks due today or tomorrow with actionable buttons
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
// Query tasks due today or tomorrow with full task data for button types
var dueSoonTasks []models.Task
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
today, dayAfterTomorrow, today, dayAfterTomorrow).
Where("is_cancelled = false").
Where("is_archived = false").
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
Find(&dueSoonTasks).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")
log.Info().Int("count", len(dueSoonTasks)).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})
// Group tasks by user (assigned_to or residence owner)
userTasks := make(map[uint][]models.Task)
for _, t := range dueSoonTasks {
var userID uint
if t.AssignedToID != nil {
userID = *t.AssignedToID
} else if t.Residence.ID != 0 {
userID = t.Residence.OwnerID
} else {
continue
}
userTasks[userID] = append(userTasks[userID], t)
}
// Send notifications to each user
for userID, userTaskList := range userTasks {
// Send actionable notifications to each user
for userID, taskList := range userTasks {
// Check user notification preferences
var prefs models.NotificationPreference
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
@@ -128,48 +111,27 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
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 individual actionable notification for each task (up to 5)
maxNotifications := 5
if len(taskList) < maxNotifications {
maxNotifications = len(taskList)
}
// 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")
for i := 0; i < maxNotifications; i++ {
t := taskList[i]
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, models.NotificationTaskDueSoon, &t); err != nil {
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Msg("Failed to send task reminder notification")
}
}
// 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")
// If more than 5 tasks, send a summary notification
if len(taskList) > 5 {
title := "More Tasks Due Soon"
body := fmt.Sprintf("You have %d more tasks due soon", len(taskList)-5)
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
"type": "task_reminder_summary",
}); err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder summary")
}
}
}
@@ -178,67 +140,46 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
return nil
}
// HandleOverdueReminder processes overdue task notifications
// HandleOverdueReminder processes overdue task notifications with actionable buttons
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
// Query overdue tasks with full task data for button types
var overdueTasks []models.Task
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
Joins("JOIN residence_residence r ON task_task.residence_id = r.id").
Where("task_task.due_date < ? OR task_task.next_due_date < ?", today, today).
Where("task_task.is_cancelled = false").
Where("task_task.is_archived = false").
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
Find(&overdueTasks).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")
log.Info().Int("count", len(overdueTasks)).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})
// Group tasks by user (assigned_to or residence owner)
userTasks := make(map[uint][]models.Task)
for _, t := range overdueTasks {
var userID uint
if t.AssignedToID != nil {
userID = *t.AssignedToID
} else if t.Residence.ID != 0 {
userID = t.Residence.OwnerID
} else {
continue
}
userTasks[userID] = append(userTasks[userID], t)
}
// Send notifications to each user
for userID, userTaskList := range userTasks {
// Send actionable notifications to each user
for userID, taskList := range userTasks {
// Check user notification preferences
var prefs models.NotificationPreference
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
@@ -253,37 +194,28 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
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)
// Send individual actionable notification for each task (up to 5)
maxNotifications := 5
if len(taskList) < maxNotifications {
maxNotifications = len(taskList)
}
for i := 0; i < maxNotifications; i++ {
t := taskList[i]
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, models.NotificationTaskOverdue, &t); err != nil {
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Msg("Failed to send overdue notification")
}
} 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")
// If more than 5 tasks, send a summary notification
if len(taskList) > 5 {
title := "More Overdue Tasks"
body := fmt.Sprintf("You have %d more overdue tasks that need attention", len(taskList)-5)
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
"type": "overdue_summary",
}); err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue summary")
}
}
}
@@ -313,7 +245,7 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
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
FROM auth_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
)