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:
treyt
2026-02-24 21:32:09 -06:00
parent 806bd07f80
commit e26116e2cf
50 changed files with 1681 additions and 97 deletions

View File

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

View File

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

View File

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

View File

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