Migrate Auth/Contractor/Document/Notification/Subscription services to ctx
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled

Every public method on these five services now takes ctx context.Context as
the first arg and routes its repo calls through .WithContext(ctx). With
TaskService and ResidenceService already migrated, this means every
in-process service that touches Postgres now produces a flame graph in
Jaeger where the SQL spans nest under the parent HTTP request span.

Endpoints now fully traced (HTTP → service → SQL):
- /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification
- /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile
- /api/contractors/* (CRUD + favorite + by-residence + tasks)
- /api/documents/* (CRUD + activate/deactivate + image upload/delete)
- /api/notifications/* (list, count, mark-read, prefs, devices)
- /api/subscription/* (status, purchase, cancel, triggers, promotions)
- All previously-migrated /api/tasks/* and /api/residences/* paths

Internal helpers also threaded:
- TaskService.sendTaskCompletedNotification → forwards ctx
- TaskService.UpdateUserTimezone → forwards ctx to NotificationService
- ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit
- NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx

~75 method signatures, ~120 handler/test call sites updated. Tests pass
green; the only failure is the pre-existing flaky TaskHandler_QuickComplete
SQLite race that fails ~60% of runs on master.

Step 3 of the observability plan is now genuinely complete: every API
endpoint backed by a Go service emits a per-request flame graph with
HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-25 16:26:21 -05:00
parent 65a9aae4e5
commit e881d37de0
20 changed files with 529 additions and 522 deletions
+42 -42
View File
@@ -98,8 +98,8 @@ func NewSubscriptionService(
}
// GetSubscription gets the subscription for a user
func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
func (s *SubscriptionService) GetSubscription(ctx context.Context, userID uint) (*SubscriptionResponse, error) {
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -107,13 +107,13 @@ func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionRespons
}
// GetSubscriptionStatus gets detailed subscription status including limits
func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionStatusResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
func (s *SubscriptionService) GetSubscriptionStatus(ctx context.Context, userID uint) (*SubscriptionStatusResponse, error) {
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
settings, err := s.subscriptionRepo.GetSettings()
settings, err := s.subscriptionRepo.WithContext(ctx).GetSettings()
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -122,18 +122,18 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
if !sub.TrialUsed && sub.TrialEnd == nil && settings.TrialEnabled {
now := time.Now().UTC()
trialEnd := now.Add(time.Duration(settings.TrialDurationDays) * 24 * time.Hour)
if err := s.subscriptionRepo.SetTrialDates(userID, now, trialEnd); err != nil {
if err := s.subscriptionRepo.WithContext(ctx).SetTrialDates(userID, now, trialEnd); err != nil {
return nil, apperrors.Internal(err)
}
// Re-fetch after starting trial so response reflects the new state
sub, err = s.subscriptionRepo.FindByUserID(userID)
sub, err = s.subscriptionRepo.WithContext(ctx).FindByUserID(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
}
// Get all tier limits and build a map
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
allLimits, err := s.subscriptionRepo.WithContext(ctx).GetAllTierLimits()
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -154,7 +154,7 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
}
// Get current usage
usage, err := s.getUserUsage(userID)
usage, err := s.getUserUsage(ctx, userID)
if err != nil {
return nil, err
}
@@ -204,31 +204,31 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
// getUserUsage calculates current usage for a user.
// P-10: Uses CountByOwner for properties count instead of loading all owned residences.
// Uses batch COUNT queries (O(1) queries) instead of per-residence queries (O(N)).
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
func (s *SubscriptionService) getUserUsage(ctx context.Context, userID uint) (*UsageResponse, error) {
// P-10: Use CountByOwner for an efficient COUNT query instead of loading all records
propertiesCount, err := s.residenceRepo.CountByOwner(userID)
propertiesCount, err := s.residenceRepo.WithContext(ctx).CountByOwner(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Still need residence IDs for batch counting tasks/contractors/documents
residenceIDs, err := s.residenceRepo.FindResidenceIDsByOwner(userID)
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByOwner(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Count tasks, contractors, and documents across all residences with single queries each
tasksCount, err := s.taskRepo.CountByResidenceIDs(residenceIDs)
tasksCount, err := s.taskRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
contractorsCount, err := s.contractorRepo.CountByResidenceIDs(residenceIDs)
contractorsCount, err := s.contractorRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
documentsCount, err := s.documentRepo.CountByResidenceIDs(residenceIDs)
documentsCount, err := s.documentRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -242,8 +242,8 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
}
// CheckLimit checks if a user has exceeded a specific limit
func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
settings, err := s.subscriptionRepo.GetSettings()
func (s *SubscriptionService) CheckLimit(ctx context.Context, userID uint, limitType string) error {
settings, err := s.subscriptionRepo.WithContext(ctx).GetSettings()
if err != nil {
return apperrors.Internal(err)
}
@@ -253,7 +253,7 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
return nil
}
sub, err := s.subscriptionRepo.GetOrCreate(userID)
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
if err != nil {
return apperrors.Internal(err)
}
@@ -268,12 +268,12 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
return nil
}
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
limits, err := s.subscriptionRepo.WithContext(ctx).GetTierLimits(sub.Tier)
if err != nil {
return apperrors.Internal(err)
}
usage, err := s.getUserUsage(userID)
usage, err := s.getUserUsage(ctx, userID)
if err != nil {
return err
}
@@ -301,8 +301,8 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
}
// GetUpgradeTrigger gets an upgrade trigger by key
func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResponse, error) {
trigger, err := s.subscriptionRepo.GetUpgradeTrigger(key)
func (s *SubscriptionService) GetUpgradeTrigger(ctx context.Context, key string) (*UpgradeTriggerResponse, error) {
trigger, err := s.subscriptionRepo.WithContext(ctx).GetUpgradeTrigger(key)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.upgrade_trigger_not_found")
@@ -314,8 +314,8 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
// GetAllUpgradeTriggers gets all active upgrade triggers as a map keyed by trigger_key
// KMM client expects Map<String, UpgradeTriggerData>
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
func (s *SubscriptionService) GetAllUpgradeTriggers(ctx context.Context) (map[string]*UpgradeTriggerDataResponse, error) {
triggers, err := s.subscriptionRepo.WithContext(ctx).GetAllUpgradeTriggers()
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -328,8 +328,8 @@ func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTrigge
}
// GetFeatureBenefits gets all feature benefits
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
func (s *SubscriptionService) GetFeatureBenefits(ctx context.Context) ([]FeatureBenefitResponse, error) {
benefits, err := s.subscriptionRepo.WithContext(ctx).GetFeatureBenefits()
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -342,13 +342,13 @@ func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, er
}
// GetActivePromotions gets active promotions for a user
func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
func (s *SubscriptionService) GetActivePromotions(ctx context.Context, userID uint) ([]PromotionResponse, error) {
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier)
promotions, err := s.subscriptionRepo.WithContext(ctx).GetActivePromotions(sub.Tier)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -362,13 +362,13 @@ func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionRespo
// ProcessApplePurchase processes an Apple IAP purchase
// Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID)
func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) {
func (s *SubscriptionService) ProcessApplePurchase(ctx context.Context, userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) {
// Store receipt/transaction data
dataToStore := receiptData
if dataToStore == "" {
dataToStore = transactionID
}
if err := s.subscriptionRepo.UpdateReceiptData(userID, dataToStore); err != nil {
if err := s.subscriptionRepo.WithContext(ctx).UpdateReceiptData(userID, dataToStore); err != nil {
return nil, apperrors.Internal(err)
}
@@ -406,18 +406,18 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Str("env", result.Environment).Msg("Apple purchase validated")
// Upgrade to Pro with the validated expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
if err := s.subscriptionRepo.WithContext(ctx).UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
return s.GetSubscription(ctx, userID)
}
// ProcessGooglePurchase processes a Google Play purchase
// productID is optional but helps validate the specific subscription
func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
// Store purchase token first
if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil {
if err := s.subscriptionRepo.WithContext(ctx).UpdatePurchaseToken(userID, purchaseToken); err != nil {
return nil, apperrors.Internal(err)
}
@@ -463,25 +463,25 @@ func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken s
}
// Upgrade to Pro with the validated expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
if err := s.subscriptionRepo.WithContext(ctx).UpgradeToPro(userID, expiresAt, "android"); err != nil {
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
return s.GetSubscription(ctx, userID)
}
// CancelSubscription cancels a subscription (downgrades to free at end of period)
func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResponse, error) {
if err := s.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
func (s *SubscriptionService) CancelSubscription(ctx context.Context, userID uint) (*SubscriptionResponse, error) {
if err := s.subscriptionRepo.WithContext(ctx).SetAutoRenew(userID, false); err != nil {
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
return s.GetSubscription(ctx, userID)
}
// IsAlreadyProFromOtherPlatform checks if a user already has an active Pro subscription
// from a different platform than the one being requested. Returns (conflict, existingPlatform, error).
func (s *SubscriptionService) IsAlreadyProFromOtherPlatform(userID uint, requestedPlatform string) (bool, string, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
func (s *SubscriptionService) IsAlreadyProFromOtherPlatform(ctx context.Context, userID uint, requestedPlatform string) (bool, string, error) {
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
if err != nil {
return false, "", apperrors.Internal(err)
}