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

@@ -1,12 +1,12 @@
# Casera API (Go)
Go implementation of the Casera property management API, built with Gin, GORM, Gorush, and GoAdmin.
Go implementation of the Casera property management API, built with Gin, GORM, and GoAdmin.
## Tech Stack
- **HTTP Framework**: [Gin](https://github.com/gin-gonic/gin)
- **ORM**: [GORM](https://gorm.io/) with PostgreSQL
- **Push Notifications**: [Gorush](https://github.com/appleboy/gorush) (embedded)
- **Push Notifications**: Direct APNs (via [apns2](https://github.com/sideshow/apns2)) + FCM HTTP API
- **Admin Panel**: [GoAdmin](https://github.com/GoAdminGroup/go-admin)
- **Background Jobs**: [Asynq](https://github.com/hibiken/asynq)
- **Caching**: Redis
@@ -66,7 +66,7 @@ myCribAPI-go/
│ ├── middleware/ # Gin middleware
│ ├── dto/ # Request/Response DTOs
│ ├── router/ # Route setup
│ ├── push/ # Gorush integration
│ ├── push/ # APNs/FCM push notifications
│ ├── worker/ # Asynq jobs
│ └── admin/ # GoAdmin tables
├── pkg/

View File

@@ -13,6 +13,7 @@ import (
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/database"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/worker/jobs"
"github.com/treytartt/casera-api/pkg/utils"
@@ -58,6 +59,11 @@ func main() {
log.Info().Str("host", cfg.Email.Host).Msg("Email service initialized")
}
// Initialize notification service for actionable push notifications
notificationRepo := repositories.NewNotificationRepository(db)
notificationService := services.NewNotificationService(notificationRepo, pushClient)
log.Info().Msg("Notification service initialized")
// Parse Redis URL for Asynq
redisOpt, err := asynq.ParseRedisURI(cfg.Redis.URL)
if err != nil {
@@ -85,7 +91,7 @@ func main() {
)
// Create job handler
jobHandler := jobs.NewHandler(db, pushClient, emailService, cfg)
jobHandler := jobs.NewHandler(db, pushClient, emailService, notificationService, cfg)
// Create Asynq mux and register handlers
mux := asynq.NewServeMux()

1
dev.sh
View File

@@ -179,7 +179,6 @@ case "$1" in
echo " worker - Background job worker"
echo " db - PostgreSQL database"
echo " redis - Redis cache"
echo " gorush - Push notification server"
echo ""
echo "Examples:"
echo " ./dev.sh # Start dev environment"

View File

@@ -38,37 +38,6 @@ services:
networks:
- casera-network
# Gorush Push Notification Server
# Note: Disabled by default. Start with: docker-compose --profile push up
gorush:
image: appleboy/gorush:latest
container_name: casera-gorush
restart: unless-stopped
profiles:
- push # Only start when push profile is enabled
ports:
- "${GORUSH_PORT:-8088}:8088"
volumes:
- ./push_certs:/certs:ro
environment:
GORUSH_CORE_PORT: "8088"
GORUSH_CORE_SYNC: "true"
GORUSH_IOS_ENABLED: "${GORUSH_IOS_ENABLED:-true}"
GORUSH_IOS_KEY_PATH: "/certs/apns_key.p8"
GORUSH_IOS_KEY_ID: "${APNS_AUTH_KEY_ID}"
GORUSH_IOS_TEAM_ID: "${APNS_TEAM_ID}"
GORUSH_IOS_TOPIC: "${APNS_TOPIC:-com.example.casera}"
GORUSH_IOS_PRODUCTION: "${APNS_PRODUCTION:-false}"
GORUSH_ANDROID_ENABLED: "${GORUSH_ANDROID_ENABLED:-true}"
GORUSH_ANDROID_APIKEY: "${FCM_SERVER_KEY}"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8088/api/stat/go"]
interval: 30s
timeout: 10s
retries: 3
networks:
- casera-network
# Casera API
api:
build:
@@ -107,8 +76,7 @@ services:
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Casera <noreply@casera.com>}
EMAIL_USE_TLS: "${EMAIL_USE_TLS:-true}"
# Push Notifications
GORUSH_URL: "http://gorush:8088"
# Push Notifications (Direct APNs/FCM - no Gorush)
APNS_AUTH_KEY_PATH: "/certs/apns_key.p8"
APNS_AUTH_KEY_ID: ${APNS_AUTH_KEY_ID}
APNS_TEAM_ID: ${APNS_TEAM_ID}
@@ -123,8 +91,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
# gorush: # Optional - enable when push notifications are configured
# condition: service_started
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000/api/health/"]
interval: 30s
@@ -179,8 +145,13 @@ services:
# Security
SECRET_KEY: ${SECRET_KEY:-dev-secret-key-change-in-production-min-32-chars}
# Push Notifications
GORUSH_URL: "http://gorush:8088"
# Push Notifications (Direct APNs/FCM - no Gorush)
APNS_AUTH_KEY_PATH: "/certs/apns_key.p8"
APNS_AUTH_KEY_ID: ${APNS_AUTH_KEY_ID}
APNS_TEAM_ID: ${APNS_TEAM_ID}
APNS_TOPIC: ${APNS_TOPIC:-com.example.casera}
APNS_USE_SANDBOX: "${APNS_USE_SANDBOX:-true}"
FCM_SERVER_KEY: ${FCM_SERVER_KEY}
# Email
EMAIL_HOST: ${EMAIL_HOST:-smtp.gmail.com}

View File

@@ -233,19 +233,17 @@ dokku config:set casera-api \
### 4. Push Notifications (Optional)
The API uses direct APNs/FCM connections (no external push server needed):
```bash
dokku config:set casera-api \
GORUSH_CORE_PORT=8080 \
GORUSH_IOS_ENABLED=true \
GORUSH_IOS_KEY_PATH=/push_certs/AuthKey_R9N3SM2WD5.p8 \
GORUSH_IOS_KEY_ID=R9N3SM2WD5 \
GORUSH_IOS_TEAM_ID=V3PF3M6B6U \
GORUSH_IOS_TOPIC=com.tt.casera.CaseraDev \
GORUSH_IOS_PRODUCTION=true \
GORUSH_ANDROID_ENABLED=false
APNS_AUTH_KEY_PATH=/push_certs/AuthKey_R9N3SM2WD5.p8 \
APNS_AUTH_KEY_ID=R9N3SM2WD5 \
APNS_TEAM_ID=V3PF3M6B6U \
APNS_TOPIC=com.tt.casera.CaseraDev \
APNS_PRODUCTION=true \
FCM_SERVER_KEY=your-firebase-server-key
```
// GORUSH_ANDROID_APIKEY=your-firebase-server-key
### 5. Admin Panel URL
@@ -387,43 +385,6 @@ dokku logs casera-api -p worker
---
#### 7. Set Proxy Port
```bash
dokku proxy:ports-set gorush http:80:8080
```
#### 8. Restart Gorush
```bash
dokku ps:restart gorush
```
#### 9. Configure Casera API to Use Gorush
```bash
dokku config:set casera-api GORUSH_URL=http://gorush.web:8080
```
#### 10. Verify Gorush is Running
```bash
# Check status
dokku ps:report gorush
# Check logs
dokku logs gorush
# Test health endpoint (if you have a domain set)
curl http://gorush.yourdomain.com/api/stat/go
```
### Option B: Use External Push Service
Configure the app to use an external push notification service instead.
---
## Maintenance Commands
### View Logs
@@ -597,7 +558,9 @@ df -h
| `EMAIL_HOST_PASSWORD` | Yes | SMTP password |
| `APPLE_CLIENT_ID` | No | iOS Bundle ID |
| `APPLE_TEAM_ID` | No | Apple Developer Team ID |
| `GORUSH_URL` | No | Push notification server URL |
| `APNS_AUTH_KEY_PATH` | No | Path to APNs .p8 key |
| `APNS_AUTH_KEY_ID` | No | APNs Key ID |
| `APNS_TEAM_ID` | No | APNs Team ID |
| `APNS_TOPIC` | No | APNs topic (bundle ID) |
| `APNS_PRODUCTION` | No | Use production APNs (default: false) |
| `FCM_SERVER_KEY` | No | Firebase Cloud Messaging server key |

294
docs/PUSH_NOTIFICATIONS.md Normal file
View File

@@ -0,0 +1,294 @@
# Push Notifications Architecture
This document describes how push notifications work in the Casera API.
## Overview
The Casera API sends push notifications directly to Apple Push Notification service (APNs) and Firebase Cloud Messaging (FCM) without any intermediate push server. This approach:
- Reduces infrastructure complexity (no Gorush or other push server needed)
- Provides direct control over notification payloads
- Supports actionable notifications with custom button types
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Casera API │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌─────────────────────┐ ┌───────────────────┐ │
│ │ Handlers │───▶│ NotificationService │───▶│ PushClient │ │
│ │ (HTTP API) │ │ │ │ │ │
│ └──────────────┘ │ - Create record │ │ ┌─────────────┐ │ │
│ │ - Get button types │ │ │ APNsClient │──┼───┼──▶ APNs
│ ┌──────────────┐ │ - Send push │ │ │ (apns2) │ │ │
│ │ Worker │───▶│ │ │ └─────────────┘ │ │
│ │ (Asynq) │ └─────────────────────┘ │ │ │
│ └──────────────┘ │ ┌─────────────┐ │ │
│ │ │ FCMClient │──┼───┼──▶ FCM
│ │ │ (HTTP) │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Components
### 1. Push Client (`internal/push/client.go`)
The unified push client that wraps both APNs and FCM clients:
```go
type Client struct {
apns *APNsClient // iOS
fcm *FCMClient // Android
}
```
**Key Methods:**
- `SendToIOS()` - Send to iOS devices
- `SendToAndroid()` - Send to Android devices
- `SendToAll()` - Send to both platforms
- `SendActionableNotification()` - Send with iOS category for action buttons
### 2. APNs Client (`internal/push/apns.go`)
Uses the [sideshow/apns2](https://github.com/sideshow/apns2) library for direct APNs communication:
- **Token-based authentication** using .p8 key file
- **Production/Sandbox** modes based on configuration
- **Batch sending** with per-token error handling
- **Category support** for actionable notifications
### 3. FCM Client (`internal/push/fcm.go`)
Uses direct HTTP calls to the FCM legacy API (`https://fcm.googleapis.com/fcm/send`):
- **Server key authentication**
- **Batch sending** via `registration_ids`
- **Data payload** for Android action handling
### 4. Notification Service (`internal/services/notification_service.go`)
Orchestrates notification creation and delivery:
```go
func (s *NotificationService) CreateAndSendTaskNotification(
ctx context.Context,
userID uint,
notifType models.NotificationType,
task *models.Task,
) error
```
**Responsibilities:**
1. Create notification record in database
2. Determine button types based on task state
3. Map button types to iOS category
4. Get user's device tokens
5. Send via PushClient
### 5. Task Button Types (`internal/services/task_button_types.go`)
Determines which action buttons to show based on task state:
| Task State | Button Types |
|------------|--------------|
| Overdue | `edit`, `complete`, `cancel`, `mark_in_progress` |
| Due Soon | `edit`, `complete`, `cancel`, `mark_in_progress` |
| In Progress | `edit`, `complete`, `cancel` |
| Cancelled | `edit` |
| Completed | `edit` |
## Scheduled Notifications
The worker process (`cmd/worker/main.go`) sends scheduled notifications using Asynq:
### Schedule
| Job | Default Time (UTC) | Config Variable |
|-----|-------------------|-----------------|
| Task Reminders | 8:00 PM | `TASK_REMINDER_HOUR`, `TASK_REMINDER_MINUTE` |
| Overdue Alerts | 9:00 AM | `OVERDUE_REMINDER_HOUR` |
| Daily Digest | 11:00 AM | `DAILY_DIGEST_HOUR` |
### Job Handlers (`internal/worker/jobs/handler.go`)
1. **HandleTaskReminder** - Sends actionable notifications for tasks due today/tomorrow
2. **HandleOverdueReminder** - Sends actionable notifications for overdue tasks
3. **HandleDailyDigest** - Sends summary notification with task statistics
## iOS Categories
iOS actionable notifications use categories to define available actions. The API sends a `category` field that maps to categories registered in the iOS app:
| Category ID | Actions |
|-------------|---------|
| `TASK_ACTIONABLE` | Complete, Edit, Skip, In Progress |
| `TASK_IN_PROGRESS` | Complete, Edit, Cancel |
| `TASK_CANCELLED` | Edit |
| `TASK_COMPLETED` | Edit |
The iOS app must register these categories in `AppDelegate`:
```swift
let completeAction = UNNotificationAction(identifier: "COMPLETE", title: "Complete")
let editAction = UNNotificationAction(identifier: "EDIT", title: "Edit")
// ... more actions
let taskCategory = UNNotificationCategory(
identifier: "TASK_ACTIONABLE",
actions: [completeAction, editAction, skipAction, inProgressAction],
intentIdentifiers: []
)
```
## Configuration
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `APNS_AUTH_KEY_PATH` | For iOS | Path to .p8 key file |
| `APNS_AUTH_KEY_ID` | For iOS | Key ID from Apple Developer |
| `APNS_TEAM_ID` | For iOS | Team ID from Apple Developer |
| `APNS_TOPIC` | For iOS | Bundle ID (e.g., `com.tt.casera.CaseraDev`) |
| `APNS_PRODUCTION` | No | `true` for production, `false` for sandbox |
| `APNS_USE_SANDBOX` | No | Deprecated, use `APNS_PRODUCTION` |
| `FCM_SERVER_KEY` | For Android | Firebase Cloud Messaging server key |
### Docker/Dokku Setup
Mount the .p8 key file:
```bash
# Docker
volumes:
- ./push_certs:/certs:ro
# Dokku
dokku storage:mount casera-api /path/to/push_certs:/certs
```
## Data Flow
### Real-time Notification (e.g., task assigned)
```
1. User assigns task via API
2. Handler calls NotificationService.CreateAndSendTaskNotification()
3. NotificationService:
a. Creates Notification record in database
b. Calls GetButtonTypesForTask() to determine actions
c. Maps buttons to iOS category
d. Gets user's device tokens from APNSDevice/GCMDevice tables
e. Calls PushClient.SendActionableNotification()
4. PushClient sends to APNs and/or FCM
5. Device receives notification with action buttons
```
### Scheduled Notification (e.g., daily overdue alert)
```
1. Asynq scheduler triggers job at configured time
2. Worker calls HandleOverdueReminder()
3. Handler:
a. Queries all overdue tasks with Preloads
b. Groups tasks by user (assigned_to or residence owner)
c. Checks user's notification preferences
d. For each user with enabled notifications:
- Sends up to 5 individual actionable notifications
- Sends summary if more than 5 tasks
4. NotificationService creates records and sends via PushClient
```
## Device Token Management
Device tokens are stored in two tables:
- `push_notifications_apnsdevice` - iOS device tokens
- `push_notifications_gcmdevice` - Android device tokens (FCM)
Fields:
- `registration_id` - The device token
- `user_id` - Associated user
- `active` - Whether to send to this device
- `device_id` - Unique device identifier
Tokens are registered via:
- `POST /api/push/apns/` - Register iOS device
- `POST /api/push/gcm/` - Register Android device
## Error Handling
### APNs Errors
The APNs client handles individual token failures:
- Logs each failure with token prefix and reason
- Continues sending to remaining tokens
- Returns error only if ALL tokens fail
Common APNs errors:
- `BadDeviceToken` - Invalid or expired token
- `Unregistered` - App uninstalled
- `TopicDisallowed` - Bundle ID mismatch
### FCM Errors
The FCM client parses the response to identify failed tokens:
- Logs failures with error type
- Returns error only if ALL tokens fail
Common FCM errors:
- `InvalidRegistration` - Invalid token
- `NotRegistered` - App uninstalled
- `MismatchSenderId` - Wrong server key
## Debugging
### Check Push Configuration
```go
log.Info().
Bool("ios_enabled", pushClient.IsIOSEnabled()).
Bool("android_enabled", pushClient.IsAndroidEnabled()).
Msg("Push notification client initialized")
```
### View Worker Logs
```bash
# Docker
docker-compose logs -f worker
# Dokku
dokku logs casera-api -p worker
```
### Test Push Manually
The API has a test endpoint (debug mode only):
```bash
curl -X POST https://api.example.com/api/push/test/ \
-H "Authorization: Token <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Test", "message": "Hello"}'
```
## Migration from Gorush
The API previously used Gorush as an intermediate push server. The migration to direct APNs/FCM involved:
1. **Added** `internal/push/apns.go` - Direct APNs using apns2 library
2. **Added** `internal/push/fcm.go` - Direct FCM using HTTP
3. **Added** `internal/push/client.go` - Unified client wrapper
4. **Removed** Gorush service from docker-compose.yml
5. **Removed** `GORUSH_URL` configuration
Benefits of direct approach:
- No additional container/service to manage
- Lower latency (one less hop)
- Full control over notification payload
- Simplified debugging

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
)