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:
@@ -17,17 +17,19 @@ import (
|
||||
|
||||
// ResidenceHandler handles residence-related HTTP requests
|
||||
type ResidenceHandler struct {
|
||||
residenceService *services.ResidenceService
|
||||
pdfService *services.PDFService
|
||||
emailService *services.EmailService
|
||||
residenceService *services.ResidenceService
|
||||
pdfService *services.PDFService
|
||||
emailService *services.EmailService
|
||||
pdfReportsEnabled bool
|
||||
}
|
||||
|
||||
// NewResidenceHandler creates a new residence handler
|
||||
func NewResidenceHandler(residenceService *services.ResidenceService, pdfService *services.PDFService, emailService *services.EmailService) *ResidenceHandler {
|
||||
func NewResidenceHandler(residenceService *services.ResidenceService, pdfService *services.PDFService, emailService *services.EmailService, pdfReportsEnabled bool) *ResidenceHandler {
|
||||
return &ResidenceHandler{
|
||||
residenceService: residenceService,
|
||||
pdfService: pdfService,
|
||||
emailService: emailService,
|
||||
residenceService: residenceService,
|
||||
pdfService: pdfService,
|
||||
emailService: emailService,
|
||||
pdfReportsEnabled: pdfReportsEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +285,10 @@ func (h *ResidenceHandler) GetResidenceTypes(c echo.Context) error {
|
||||
// GenerateTasksReport handles POST /api/residences/:id/generate-tasks-report/
|
||||
// Generates a PDF report of tasks for the residence and emails it
|
||||
func (h *ResidenceHandler) GenerateTasksReport(c echo.Context) error {
|
||||
if !h.pdfReportsEnabled {
|
||||
return apperrors.BadRequest("error.feature_disabled")
|
||||
}
|
||||
|
||||
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||
|
||||
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
@@ -25,7 +25,7 @@ func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *echo.Echo, *gorm.D
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
cfg := &config.Config{}
|
||||
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
|
||||
handler := NewResidenceHandler(residenceService, nil, nil)
|
||||
handler := NewResidenceHandler(residenceService, nil, nil, true)
|
||||
e := testutil.SetupTestRouter()
|
||||
return handler, e, db
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ func TestTaskHandler_UncancelTask(t *testing.T) {
|
||||
|
||||
// Cancel first
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
taskRepo.Cancel(task.ID)
|
||||
taskRepo.Cancel(task.ID, task.Version)
|
||||
|
||||
authGroup := e.Group("/api/tasks")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
@@ -418,7 +418,7 @@ func TestTaskHandler_UnarchiveTask(t *testing.T) {
|
||||
|
||||
// Archive first
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
taskRepo.Archive(task.ID)
|
||||
taskRepo.Archive(task.ID, task.Version)
|
||||
|
||||
authGroup := e.Group("/api/tasks")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
|
||||
Reference in New Issue
Block a user