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