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:
@@ -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/
|
||||
|
||||
@@ -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
1
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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
294
docs/PUSH_NOTIFICATIONS.md
Normal 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
|
||||
@@ -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