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