Add webhook logging, pagination, middleware, migrations, and prod hardening
- Webhook event logging repo and subscription webhook idempotency - Pagination helper (echohelpers) with cursor/offset support - Request ID and structured logging middleware - Push client improvements (FCM HTTP v1, better error handling) - Task model version column, business constraint migrations, targeted indexes - Expanded categorization chain tests - Email service and config hardening - CI workflow updates, .gitignore additions, .env.example updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,17 +26,23 @@ import (
|
||||
type SubscriptionWebhookHandler struct {
|
||||
subscriptionRepo *repositories.SubscriptionRepository
|
||||
userRepo *repositories.UserRepository
|
||||
webhookEventRepo *repositories.WebhookEventRepository
|
||||
appleRootCerts []*x509.Certificate
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewSubscriptionWebhookHandler creates a new webhook handler
|
||||
func NewSubscriptionWebhookHandler(
|
||||
subscriptionRepo *repositories.SubscriptionRepository,
|
||||
userRepo *repositories.UserRepository,
|
||||
webhookEventRepo *repositories.WebhookEventRepository,
|
||||
enabled bool,
|
||||
) *SubscriptionWebhookHandler {
|
||||
return &SubscriptionWebhookHandler{
|
||||
subscriptionRepo: subscriptionRepo,
|
||||
userRepo: userRepo,
|
||||
webhookEventRepo: webhookEventRepo,
|
||||
enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +100,11 @@ type AppleRenewalInfo struct {
|
||||
|
||||
// HandleAppleWebhook handles POST /api/subscription/webhook/apple/
|
||||
func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
|
||||
if !h.enabled {
|
||||
log.Printf("Apple Webhook: webhooks disabled by feature flag")
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
log.Printf("Apple Webhook: Failed to read body: %v", err)
|
||||
@@ -116,6 +127,18 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
|
||||
log.Printf("Apple Webhook: Received %s (subtype: %s) for bundle %s",
|
||||
notification.NotificationType, notification.Subtype, notification.Data.BundleID)
|
||||
|
||||
// Dedup check using notificationUUID
|
||||
if notification.NotificationUUID != "" {
|
||||
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
|
||||
if err != nil {
|
||||
log.Printf("Apple Webhook: Failed to check dedup: %v", err)
|
||||
// Continue processing on dedup check failure (fail-open)
|
||||
} else if alreadyProcessed {
|
||||
log.Printf("Apple Webhook: Duplicate event %s, skipping", notification.NotificationUUID)
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify bundle ID matches our app
|
||||
cfg := config.Get()
|
||||
if cfg != nil && cfg.AppleIAP.BundleID != "" {
|
||||
@@ -145,6 +168,13 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
|
||||
// Still return 200 to prevent Apple from retrying
|
||||
}
|
||||
|
||||
// Record processed event for dedup
|
||||
if notification.NotificationUUID != "" {
|
||||
if err := h.webhookEventRepo.RecordEvent("apple", notification.NotificationUUID, notification.NotificationType, ""); err != nil {
|
||||
log.Printf("Apple Webhook: Failed to record event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Always return 200 OK to acknowledge receipt
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
|
||||
}
|
||||
@@ -450,6 +480,11 @@ const (
|
||||
|
||||
// HandleGoogleWebhook handles POST /api/subscription/webhook/google/
|
||||
func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
|
||||
if !h.enabled {
|
||||
log.Printf("Google Webhook: webhooks disabled by feature flag")
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
log.Printf("Google Webhook: Failed to read body: %v", err)
|
||||
@@ -475,6 +510,19 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid developer notification"})
|
||||
}
|
||||
|
||||
// Dedup check using messageId
|
||||
messageID := notification.Message.MessageID
|
||||
if messageID != "" {
|
||||
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
|
||||
if err != nil {
|
||||
log.Printf("Google Webhook: Failed to check dedup: %v", err)
|
||||
// Continue processing on dedup check failure (fail-open)
|
||||
} else if alreadyProcessed {
|
||||
log.Printf("Google Webhook: Duplicate event %s, skipping", messageID)
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle test notification
|
||||
if devNotification.TestNotification != nil {
|
||||
log.Printf("Google Webhook: Received test notification")
|
||||
@@ -499,6 +547,17 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Record processed event for dedup
|
||||
if messageID != "" {
|
||||
eventType := "unknown"
|
||||
if devNotification.SubscriptionNotification != nil {
|
||||
eventType = fmt.Sprintf("subscription_%d", devNotification.SubscriptionNotification.NotificationType)
|
||||
}
|
||||
if err := h.webhookEventRepo.RecordEvent("google", messageID, eventType, ""); err != nil {
|
||||
log.Printf("Google Webhook: Failed to record event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledge the message
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user