From 5a6bad3ec3d29002603a0e0e1a3cf64870c126d6 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 6 Dec 2025 00:59:42 -0600 Subject: [PATCH] Remove Gorush, use direct APNs/FCM, fix worker queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 6 +- cmd/worker/main.go | 8 +- dev.sh | 1 - docker-compose.yml | 45 +---- docs/DOKKU_SETUP.md | 59 ++----- docs/PUSH_NOTIFICATIONS.md | 294 ++++++++++++++++++++++++++++++++ internal/config/config.go | 9 +- internal/worker/jobs/handler.go | 264 +++++++++++----------------- 8 files changed, 423 insertions(+), 263 deletions(-) create mode 100644 docs/PUSH_NOTIFICATIONS.md diff --git a/README.md b/README.md index cae71cf..1c56099 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/cmd/worker/main.go b/cmd/worker/main.go index a38e11e..766125c 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -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() diff --git a/dev.sh b/dev.sh index 776ad12..ea8ccb4 100755 --- a/dev.sh +++ b/dev.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 4af45e4..f077d53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 } 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} diff --git a/docs/DOKKU_SETUP.md b/docs/DOKKU_SETUP.md index 4ed4f75..7cbb5f4 100644 --- a/docs/DOKKU_SETUP.md +++ b/docs/DOKKU_SETUP.md @@ -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 | diff --git a/docs/PUSH_NOTIFICATIONS.md b/docs/PUSH_NOTIFICATIONS.md new file mode 100644 index 0000000..5d6c83b --- /dev/null +++ b/docs/PUSH_NOTIFICATIONS.md @@ -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 " \ + -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 diff --git a/internal/config/config.go b/internal/config/config.go index cfb862d..a7eaf43 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 ") // 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) diff --git a/internal/worker/jobs/handler.go b/internal/worker/jobs/handler.go index 64a9456..bf1b9d9 100644 --- a/internal/worker/jobs/handler.go +++ b/internal/worker/jobs/handler.go @@ -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 )