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)
|
# 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
|
## Tech Stack
|
||||||
|
|
||||||
- **HTTP Framework**: [Gin](https://github.com/gin-gonic/gin)
|
- **HTTP Framework**: [Gin](https://github.com/gin-gonic/gin)
|
||||||
- **ORM**: [GORM](https://gorm.io/) with PostgreSQL
|
- **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)
|
- **Admin Panel**: [GoAdmin](https://github.com/GoAdminGroup/go-admin)
|
||||||
- **Background Jobs**: [Asynq](https://github.com/hibiken/asynq)
|
- **Background Jobs**: [Asynq](https://github.com/hibiken/asynq)
|
||||||
- **Caching**: Redis
|
- **Caching**: Redis
|
||||||
@@ -66,7 +66,7 @@ myCribAPI-go/
|
|||||||
│ ├── middleware/ # Gin middleware
|
│ ├── middleware/ # Gin middleware
|
||||||
│ ├── dto/ # Request/Response DTOs
|
│ ├── dto/ # Request/Response DTOs
|
||||||
│ ├── router/ # Route setup
|
│ ├── router/ # Route setup
|
||||||
│ ├── push/ # Gorush integration
|
│ ├── push/ # APNs/FCM push notifications
|
||||||
│ ├── worker/ # Asynq jobs
|
│ ├── worker/ # Asynq jobs
|
||||||
│ └── admin/ # GoAdmin tables
|
│ └── admin/ # GoAdmin tables
|
||||||
├── pkg/
|
├── pkg/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/treytartt/casera-api/internal/config"
|
"github.com/treytartt/casera-api/internal/config"
|
||||||
"github.com/treytartt/casera-api/internal/database"
|
"github.com/treytartt/casera-api/internal/database"
|
||||||
"github.com/treytartt/casera-api/internal/push"
|
"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/services"
|
||||||
"github.com/treytartt/casera-api/internal/worker/jobs"
|
"github.com/treytartt/casera-api/internal/worker/jobs"
|
||||||
"github.com/treytartt/casera-api/pkg/utils"
|
"github.com/treytartt/casera-api/pkg/utils"
|
||||||
@@ -58,6 +59,11 @@ func main() {
|
|||||||
log.Info().Str("host", cfg.Email.Host).Msg("Email service initialized")
|
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
|
// Parse Redis URL for Asynq
|
||||||
redisOpt, err := asynq.ParseRedisURI(cfg.Redis.URL)
|
redisOpt, err := asynq.ParseRedisURI(cfg.Redis.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,7 +91,7 @@ func main() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Create job handler
|
// Create job handler
|
||||||
jobHandler := jobs.NewHandler(db, pushClient, emailService, cfg)
|
jobHandler := jobs.NewHandler(db, pushClient, emailService, notificationService, cfg)
|
||||||
|
|
||||||
// Create Asynq mux and register handlers
|
// Create Asynq mux and register handlers
|
||||||
mux := asynq.NewServeMux()
|
mux := asynq.NewServeMux()
|
||||||
|
|||||||
1
dev.sh
1
dev.sh
@@ -179,7 +179,6 @@ case "$1" in
|
|||||||
echo " worker - Background job worker"
|
echo " worker - Background job worker"
|
||||||
echo " db - PostgreSQL database"
|
echo " db - PostgreSQL database"
|
||||||
echo " redis - Redis cache"
|
echo " redis - Redis cache"
|
||||||
echo " gorush - Push notification server"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " ./dev.sh # Start dev environment"
|
echo " ./dev.sh # Start dev environment"
|
||||||
|
|||||||
@@ -38,37 +38,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- casera-network
|
- 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
|
# Casera API
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
@@ -107,8 +76,7 @@ services:
|
|||||||
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Casera <noreply@casera.com>}
|
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Casera <noreply@casera.com>}
|
||||||
EMAIL_USE_TLS: "${EMAIL_USE_TLS:-true}"
|
EMAIL_USE_TLS: "${EMAIL_USE_TLS:-true}"
|
||||||
|
|
||||||
# Push Notifications
|
# Push Notifications (Direct APNs/FCM - no Gorush)
|
||||||
GORUSH_URL: "http://gorush:8088"
|
|
||||||
APNS_AUTH_KEY_PATH: "/certs/apns_key.p8"
|
APNS_AUTH_KEY_PATH: "/certs/apns_key.p8"
|
||||||
APNS_AUTH_KEY_ID: ${APNS_AUTH_KEY_ID}
|
APNS_AUTH_KEY_ID: ${APNS_AUTH_KEY_ID}
|
||||||
APNS_TEAM_ID: ${APNS_TEAM_ID}
|
APNS_TEAM_ID: ${APNS_TEAM_ID}
|
||||||
@@ -123,8 +91,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# gorush: # Optional - enable when push notifications are configured
|
|
||||||
# condition: service_started
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000/api/health/"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8000/api/health/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -179,8 +145,13 @@ services:
|
|||||||
# Security
|
# Security
|
||||||
SECRET_KEY: ${SECRET_KEY:-dev-secret-key-change-in-production-min-32-chars}
|
SECRET_KEY: ${SECRET_KEY:-dev-secret-key-change-in-production-min-32-chars}
|
||||||
|
|
||||||
# Push Notifications
|
# Push Notifications (Direct APNs/FCM - no Gorush)
|
||||||
GORUSH_URL: "http://gorush:8088"
|
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
|
||||||
EMAIL_HOST: ${EMAIL_HOST:-smtp.gmail.com}
|
EMAIL_HOST: ${EMAIL_HOST:-smtp.gmail.com}
|
||||||
|
|||||||
@@ -233,19 +233,17 @@ dokku config:set casera-api \
|
|||||||
|
|
||||||
### 4. Push Notifications (Optional)
|
### 4. Push Notifications (Optional)
|
||||||
|
|
||||||
|
The API uses direct APNs/FCM connections (no external push server needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dokku config:set casera-api \
|
dokku config:set casera-api \
|
||||||
GORUSH_CORE_PORT=8080 \
|
APNS_AUTH_KEY_PATH=/push_certs/AuthKey_R9N3SM2WD5.p8 \
|
||||||
GORUSH_IOS_ENABLED=true \
|
APNS_AUTH_KEY_ID=R9N3SM2WD5 \
|
||||||
GORUSH_IOS_KEY_PATH=/push_certs/AuthKey_R9N3SM2WD5.p8 \
|
APNS_TEAM_ID=V3PF3M6B6U \
|
||||||
GORUSH_IOS_KEY_ID=R9N3SM2WD5 \
|
APNS_TOPIC=com.tt.casera.CaseraDev \
|
||||||
GORUSH_IOS_TEAM_ID=V3PF3M6B6U \
|
APNS_PRODUCTION=true \
|
||||||
GORUSH_IOS_TOPIC=com.tt.casera.CaseraDev \
|
FCM_SERVER_KEY=your-firebase-server-key
|
||||||
GORUSH_IOS_PRODUCTION=true \
|
|
||||||
GORUSH_ANDROID_ENABLED=false
|
|
||||||
|
|
||||||
```
|
```
|
||||||
// GORUSH_ANDROID_APIKEY=your-firebase-server-key
|
|
||||||
|
|
||||||
### 5. Admin Panel URL
|
### 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
|
## Maintenance Commands
|
||||||
|
|
||||||
### View Logs
|
### View Logs
|
||||||
@@ -597,7 +558,9 @@ df -h
|
|||||||
| `EMAIL_HOST_PASSWORD` | Yes | SMTP password |
|
| `EMAIL_HOST_PASSWORD` | Yes | SMTP password |
|
||||||
| `APPLE_CLIENT_ID` | No | iOS Bundle ID |
|
| `APPLE_CLIENT_ID` | No | iOS Bundle ID |
|
||||||
| `APPLE_TEAM_ID` | No | Apple Developer Team 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_PATH` | No | Path to APNs .p8 key |
|
||||||
| `APNS_AUTH_KEY_ID` | No | APNs Key ID |
|
| `APNS_AUTH_KEY_ID` | No | APNs Key ID |
|
||||||
| `APNS_TEAM_ID` | No | APNs Team 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 {
|
type PushConfig struct {
|
||||||
// Gorush server URL (deprecated - kept for backwards compatibility)
|
// APNs (iOS) - uses github.com/sideshow/apns2 for direct APNs communication
|
||||||
GorushURL string
|
|
||||||
|
|
||||||
// APNs (iOS)
|
|
||||||
APNSKeyPath string
|
APNSKeyPath string
|
||||||
APNSKeyID string
|
APNSKeyID string
|
||||||
APNSTeamID string
|
APNSTeamID string
|
||||||
@@ -71,7 +68,7 @@ type PushConfig struct {
|
|||||||
APNSSandbox bool
|
APNSSandbox bool
|
||||||
APNSProduction bool // If true, use production APNs; if false, use sandbox
|
APNSProduction bool // If true, use production APNs; if false, use sandbox
|
||||||
|
|
||||||
// FCM (Android)
|
// FCM (Android) - uses direct HTTP to FCM legacy API
|
||||||
FCMServerKey string
|
FCMServerKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +163,6 @@ func Load() (*Config, error) {
|
|||||||
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
|
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
|
||||||
},
|
},
|
||||||
Push: PushConfig{
|
Push: PushConfig{
|
||||||
GorushURL: viper.GetString("GORUSH_URL"),
|
|
||||||
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
|
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
|
||||||
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
|
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
|
||||||
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
|
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
|
||||||
@@ -244,7 +240,6 @@ func setDefaults() {
|
|||||||
viper.SetDefault("DEFAULT_FROM_EMAIL", "Casera <noreply@casera.com>")
|
viper.SetDefault("DEFAULT_FROM_EMAIL", "Casera <noreply@casera.com>")
|
||||||
|
|
||||||
// Push notification defaults
|
// Push notification defaults
|
||||||
viper.SetDefault("GORUSH_URL", "http://localhost:8088")
|
|
||||||
viper.SetDefault("APNS_TOPIC", "com.example.casera")
|
viper.SetDefault("APNS_TOPIC", "com.example.casera")
|
||||||
viper.SetDefault("APNS_USE_SANDBOX", true)
|
viper.SetDefault("APNS_USE_SANDBOX", true)
|
||||||
viper.SetDefault("APNS_PRODUCTION", false)
|
viper.SetDefault("APNS_PRODUCTION", false)
|
||||||
|
|||||||
@@ -27,19 +27,21 @@ const (
|
|||||||
|
|
||||||
// Handler handles background job processing
|
// Handler handles background job processing
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
pushClient *push.Client
|
pushClient *push.Client
|
||||||
emailService *services.EmailService
|
emailService *services.EmailService
|
||||||
config *config.Config
|
notificationService *services.NotificationService
|
||||||
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new job handler
|
// 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{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
pushClient: pushClient,
|
pushClient: pushClient,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
config: cfg,
|
notificationService: notificationService,
|
||||||
|
config: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,66 +56,47 @@ type TaskReminderData struct {
|
|||||||
ResidenceName string
|
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 {
|
func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) error {
|
||||||
log.Info().Msg("Processing task reminder notifications...")
|
log.Info().Msg("Processing task reminder notifications...")
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.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)
|
dayAfterTomorrow := today.AddDate(0, 0, 2)
|
||||||
|
|
||||||
// Query tasks due today or tomorrow that are not completed, cancelled, or archived
|
// Query tasks due today or tomorrow with full task data for button types
|
||||||
var tasks []struct {
|
var dueSoonTasks []models.Task
|
||||||
TaskID uint
|
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||||
TaskTitle string
|
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
|
||||||
DueDate time.Time
|
today, dayAfterTomorrow, today, dayAfterTomorrow).
|
||||||
UserID uint
|
Where("is_cancelled = false").
|
||||||
ResidenceName string
|
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
|
||||||
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
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to query tasks due soon")
|
log.Error().Err(err).Msg("Failed to query tasks due soon")
|
||||||
return err
|
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
|
// Group tasks by user (assigned_to or residence owner)
|
||||||
userTasks := make(map[uint][]struct {
|
userTasks := make(map[uint][]models.Task)
|
||||||
TaskID uint
|
for _, t := range dueSoonTasks {
|
||||||
TaskTitle string
|
var userID uint
|
||||||
DueDate time.Time
|
if t.AssignedToID != nil {
|
||||||
ResidenceName string
|
userID = *t.AssignedToID
|
||||||
})
|
} else if t.Residence.ID != 0 {
|
||||||
|
userID = t.Residence.OwnerID
|
||||||
for _, t := range tasks {
|
} else {
|
||||||
userTasks[t.UserID] = append(userTasks[t.UserID], struct {
|
continue
|
||||||
TaskID uint
|
}
|
||||||
TaskTitle string
|
userTasks[userID] = append(userTasks[userID], t)
|
||||||
DueDate time.Time
|
|
||||||
ResidenceName string
|
|
||||||
}{t.TaskID, t.TaskTitle, t.DueDate, t.ResidenceName})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notifications to each user
|
// Send actionable notifications to each user
|
||||||
for userID, userTaskList := range userTasks {
|
for userID, taskList := range userTasks {
|
||||||
// Check user notification preferences
|
// Check user notification preferences
|
||||||
var prefs models.NotificationPreference
|
var prefs models.NotificationPreference
|
||||||
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build notification message
|
// Send individual actionable notification for each task (up to 5)
|
||||||
var title, body string
|
maxNotifications := 5
|
||||||
if len(userTaskList) == 1 {
|
if len(taskList) < maxNotifications {
|
||||||
t := userTaskList[0]
|
maxNotifications = len(taskList)
|
||||||
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 push notification
|
for i := 0; i < maxNotifications; i++ {
|
||||||
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
t := taskList[i]
|
||||||
"type": "task_reminder",
|
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, models.NotificationTaskDueSoon, &t); err != nil {
|
||||||
}); err != nil {
|
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Msg("Failed to send task reminder notification")
|
||||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder push")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create in-app notification record
|
// If more than 5 tasks, send a summary notification
|
||||||
for _, t := range userTaskList {
|
if len(taskList) > 5 {
|
||||||
notification := &models.Notification{
|
title := "More Tasks Due Soon"
|
||||||
UserID: userID,
|
body := fmt.Sprintf("You have %d more tasks due soon", len(taskList)-5)
|
||||||
NotificationType: models.NotificationTaskDueSoon,
|
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
||||||
Title: title,
|
"type": "task_reminder_summary",
|
||||||
Body: body,
|
}); err != nil {
|
||||||
TaskID: &t.TaskID,
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder summary")
|
||||||
}
|
|
||||||
if err := h.db.Create(notification).Error; err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to create notification record")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,67 +140,46 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
|||||||
return nil
|
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 {
|
func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) error {
|
||||||
log.Info().Msg("Processing overdue task notifications...")
|
log.Info().Msg("Processing overdue task notifications...")
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.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
|
// Query overdue tasks with full task data for button types
|
||||||
var tasks []struct {
|
var overdueTasks []models.Task
|
||||||
TaskID uint
|
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||||
TaskTitle string
|
Joins("JOIN residence_residence r ON task_task.residence_id = r.id").
|
||||||
DueDate time.Time
|
Where("task_task.due_date < ? OR task_task.next_due_date < ?", today, today).
|
||||||
DaysOverdue int
|
Where("task_task.is_cancelled = false").
|
||||||
UserID uint
|
Where("task_task.is_archived = false").
|
||||||
ResidenceName string
|
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
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to query overdue tasks")
|
log.Error().Err(err).Msg("Failed to query overdue tasks")
|
||||||
return err
|
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
|
// Group tasks by user (assigned_to or residence owner)
|
||||||
userTasks := make(map[uint][]struct {
|
userTasks := make(map[uint][]models.Task)
|
||||||
TaskID uint
|
for _, t := range overdueTasks {
|
||||||
TaskTitle string
|
var userID uint
|
||||||
DaysOverdue int
|
if t.AssignedToID != nil {
|
||||||
ResidenceName string
|
userID = *t.AssignedToID
|
||||||
})
|
} else if t.Residence.ID != 0 {
|
||||||
|
userID = t.Residence.OwnerID
|
||||||
for _, t := range tasks {
|
} else {
|
||||||
userTasks[t.UserID] = append(userTasks[t.UserID], struct {
|
continue
|
||||||
TaskID uint
|
}
|
||||||
TaskTitle string
|
userTasks[userID] = append(userTasks[userID], t)
|
||||||
DaysOverdue int
|
|
||||||
ResidenceName string
|
|
||||||
}{t.TaskID, t.TaskTitle, t.DaysOverdue, t.ResidenceName})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notifications to each user
|
// Send actionable notifications to each user
|
||||||
for userID, userTaskList := range userTasks {
|
for userID, taskList := range userTasks {
|
||||||
// Check user notification preferences
|
// Check user notification preferences
|
||||||
var prefs models.NotificationPreference
|
var prefs models.NotificationPreference
|
||||||
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build notification message
|
// Send individual actionable notification for each task (up to 5)
|
||||||
var title, body string
|
maxNotifications := 5
|
||||||
if len(userTaskList) == 1 {
|
if len(taskList) < maxNotifications {
|
||||||
t := userTaskList[0]
|
maxNotifications = len(taskList)
|
||||||
title = "Overdue Task"
|
}
|
||||||
if t.DaysOverdue == 1 {
|
|
||||||
body = fmt.Sprintf("%s at %s is 1 day overdue", t.TaskTitle, t.ResidenceName)
|
for i := 0; i < maxNotifications; i++ {
|
||||||
} else {
|
t := taskList[i]
|
||||||
body = fmt.Sprintf("%s at %s is %d days overdue", t.TaskTitle, t.ResidenceName, t.DaysOverdue)
|
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 more than 5 tasks, send a summary notification
|
||||||
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
if len(taskList) > 5 {
|
||||||
"type": "overdue_reminder",
|
title := "More Overdue Tasks"
|
||||||
}); err != nil {
|
body := fmt.Sprintf("You have %d more overdue tasks that need attention", len(taskList)-5)
|
||||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue reminder push")
|
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
||||||
}
|
"type": "overdue_summary",
|
||||||
|
}); err != nil {
|
||||||
// Create in-app notification record
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue summary")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +245,7 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
|||||||
COUNT(DISTINCT t.id) as total_tasks,
|
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 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
|
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 (
|
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
|
SELECT residence_id FROM residence_residence_users WHERE user_id = u.id
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user