Remove Gorush, use direct APNs/FCM, fix worker queries

- Remove Gorush push server dependency (now using direct APNs/FCM)
- Update docker-compose.yml to remove gorush service
- Update config.go to remove GORUSH_URL
- Fix worker queries:
  - Use auth_user instead of user_user table
  - Use completed_at instead of completion_date column
- Add NotificationService to worker handler for actionable notifications
- Add docs/PUSH_NOTIFICATIONS.md with architecture documentation
- Update README.md, DOKKU_SETUP.md, and dev.sh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-06 00:59:42 -06:00
parent 91a1f7ebed
commit 5a6bad3ec3
8 changed files with 423 additions and 263 deletions

View File

@@ -1,12 +1,12 @@
# Casera API (Go) # 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/

View File

@@ -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
View File

@@ -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"

View File

@@ -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}

View File

@@ -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
View File

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

View File

@@ -60,10 +60,7 @@ type EmailConfig struct {
} }
type PushConfig struct { 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)

View File

@@ -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
) )