Add actionable push notifications and fix recurring task completion
Features: - Add task action buttons to push notifications (complete, view, cancel, etc.) - Add button types logic for different task states (overdue, in_progress, etc.) - Implement Chain of Responsibility pattern for task categorization - Add comprehensive kanban categorization documentation Fixes: - Reset recurring task status to Pending after completion so tasks appear in correct kanban column (was staying in "In Progress") - Fix PostgreSQL EXTRACT function error in overdue notifications query - Update seed data to properly set next_due_date for recurring tasks Admin: - Add tasks list to residence detail page - Fix task edit page to properly handle all fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,80 @@ func (c *APNsClient) Send(ctx context.Context, tokens []string, title, message s
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendWithCategory sends a push notification with iOS category for actionable notifications
|
||||
func (c *APNsClient) SendWithCategory(ctx context.Context, tokens []string, title, message string, data map[string]string, categoryID string) error {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the notification payload with category
|
||||
p := payload.NewPayload().
|
||||
AlertTitle(title).
|
||||
AlertBody(message).
|
||||
Sound("default").
|
||||
MutableContent().
|
||||
Category(categoryID) // iOS category for actionable notifications
|
||||
|
||||
// Add custom data
|
||||
for key, value := range data {
|
||||
p.Custom(key, value)
|
||||
}
|
||||
|
||||
var errors []error
|
||||
successCount := 0
|
||||
|
||||
for _, deviceToken := range tokens {
|
||||
notification := &apns2.Notification{
|
||||
DeviceToken: deviceToken,
|
||||
Topic: c.topic,
|
||||
Payload: p,
|
||||
Priority: apns2.PriorityHigh,
|
||||
}
|
||||
|
||||
res, err := c.client.PushWithContext(ctx, notification)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("category", categoryID).
|
||||
Msg("Failed to send APNs actionable notification")
|
||||
errors = append(errors, fmt.Errorf("token %s: %w", truncateToken(deviceToken), err))
|
||||
continue
|
||||
}
|
||||
|
||||
if !res.Sent() {
|
||||
log.Error().
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("reason", res.Reason).
|
||||
Int("status", res.StatusCode).
|
||||
Str("category", categoryID).
|
||||
Msg("APNs actionable notification not sent")
|
||||
errors = append(errors, fmt.Errorf("token %s: %s (status %d)", truncateToken(deviceToken), res.Reason, res.StatusCode))
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
log.Debug().
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("apns_id", res.ApnsID).
|
||||
Str("category", categoryID).
|
||||
Msg("APNs actionable notification sent successfully")
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("total", len(tokens)).
|
||||
Int("success", successCount).
|
||||
Int("failed", len(errors)).
|
||||
Str("category", categoryID).
|
||||
Msg("APNs actionable batch send complete")
|
||||
|
||||
if len(errors) > 0 && successCount == 0 {
|
||||
return fmt.Errorf("all APNs actionable notifications failed: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateToken returns first 8 chars of token for logging
|
||||
func truncateToken(token string) string {
|
||||
if len(token) > 8 {
|
||||
|
||||
@@ -102,6 +102,33 @@ func (c *Client) IsAndroidEnabled() bool {
|
||||
return c.fcm != nil
|
||||
}
|
||||
|
||||
// SendActionableNotification sends notifications with action button support
|
||||
// iOS receives a category for actionable notifications, Android handles actions via data payload
|
||||
func (c *Client) SendActionableNotification(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string, iosCategoryID string) error {
|
||||
var lastErr error
|
||||
|
||||
if len(iosTokens) > 0 {
|
||||
if c.apns == nil {
|
||||
log.Warn().Msg("APNs client not initialized, skipping iOS actionable push")
|
||||
} else {
|
||||
if err := c.apns.SendWithCategory(ctx, iosTokens, title, message, data, iosCategoryID); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send iOS actionable notifications")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(androidTokens) > 0 {
|
||||
// Android handles actions via data payload - existing send works
|
||||
if err := c.SendToAndroid(ctx, androidTokens, title, message, data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send Android notifications")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// HealthCheck checks if the push services are available
|
||||
func (c *Client) HealthCheck(ctx context.Context) error {
|
||||
// For direct clients, we can't easily health check without sending a notification
|
||||
|
||||
Reference in New Issue
Block a user