Migrate Auth/Contractor/Document/Notification/Subscription services to ctx
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:
@@ -44,8 +44,8 @@ func NewNotificationService(notificationRepo *repositories.NotificationRepositor
|
||||
// === Notifications ===
|
||||
|
||||
// GetNotifications gets notifications for a user
|
||||
func (s *NotificationService) GetNotifications(userID uint, limit, offset int) ([]NotificationResponse, error) {
|
||||
notifications, err := s.notificationRepo.FindByUser(userID, limit, offset)
|
||||
func (s *NotificationService) GetNotifications(ctx context.Context, userID uint, limit, offset int) ([]NotificationResponse, error) {
|
||||
notifications, err := s.notificationRepo.WithContext(ctx).FindByUser(userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -58,8 +58,8 @@ func (s *NotificationService) GetNotifications(userID uint, limit, offset int) (
|
||||
}
|
||||
|
||||
// GetUnreadCount gets the count of unread notifications
|
||||
func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) {
|
||||
count, err := s.notificationRepo.CountUnread(userID)
|
||||
func (s *NotificationService) GetUnreadCount(ctx context.Context, userID uint) (int64, error) {
|
||||
count, err := s.notificationRepo.WithContext(ctx).CountUnread(userID)
|
||||
if err != nil {
|
||||
return 0, apperrors.Internal(err)
|
||||
}
|
||||
@@ -67,8 +67,8 @@ func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) {
|
||||
}
|
||||
|
||||
// MarkAsRead marks a notification as read
|
||||
func (s *NotificationService) MarkAsRead(notificationID, userID uint) error {
|
||||
notification, err := s.notificationRepo.FindByID(notificationID)
|
||||
func (s *NotificationService) MarkAsRead(ctx context.Context, notificationID, userID uint) error {
|
||||
notification, err := s.notificationRepo.WithContext(ctx).FindByID(notificationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperrors.NotFound("error.notification_not_found")
|
||||
@@ -80,15 +80,15 @@ func (s *NotificationService) MarkAsRead(notificationID, userID uint) error {
|
||||
return apperrors.NotFound("error.notification_not_found")
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.MarkAsRead(notificationID); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).MarkAsRead(notificationID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all notifications as read
|
||||
func (s *NotificationService) MarkAllAsRead(userID uint) error {
|
||||
if err := s.notificationRepo.MarkAllAsRead(userID); err != nil {
|
||||
func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID uint) error {
|
||||
if err := s.notificationRepo.WithContext(ctx).MarkAllAsRead(userID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
return nil
|
||||
@@ -97,7 +97,7 @@ func (s *NotificationService) MarkAllAsRead(userID uint) error {
|
||||
// CreateAndSendNotification creates a notification and sends it via push
|
||||
func (s *NotificationService) CreateAndSendNotification(ctx context.Context, userID uint, notificationType models.NotificationType, title, body string, data map[string]interface{}) error {
|
||||
// Check user preferences
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
@@ -117,12 +117,12 @@ func (s *NotificationService) CreateAndSendNotification(ctx context.Context, use
|
||||
Data: string(dataJSON),
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.Create(notification); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).Create(notification); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get device tokens
|
||||
iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID)
|
||||
iosTokens, androidTokens, err := s.notificationRepo.WithContext(ctx).GetActiveTokensForUser(userID)
|
||||
if err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
@@ -144,12 +144,12 @@ func (s *NotificationService) CreateAndSendNotification(ctx context.Context, use
|
||||
if s.pushClient != nil {
|
||||
err = s.pushClient.SendToAll(ctx, iosTokens, androidTokens, title, body, pushData)
|
||||
if err != nil {
|
||||
s.notificationRepo.SetError(notification.ID, err.Error())
|
||||
s.notificationRepo.WithContext(ctx).SetError(notification.ID, err.Error())
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.MarkAsSent(notification.ID); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).MarkAsSent(notification.ID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
return nil
|
||||
@@ -178,8 +178,8 @@ func (s *NotificationService) isNotificationEnabled(prefs *models.NotificationPr
|
||||
// === Notification Preferences ===
|
||||
|
||||
// GetPreferences gets notification preferences for a user
|
||||
func (s *NotificationService) GetPreferences(userID uint) (*NotificationPreferencesResponse, error) {
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
func (s *NotificationService) GetPreferences(ctx context.Context, userID uint) (*NotificationPreferencesResponse, error) {
|
||||
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func validateHourField(val *int, fieldName string) error {
|
||||
}
|
||||
|
||||
// UpdatePreferences updates notification preferences
|
||||
func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferencesRequest) (*NotificationPreferencesResponse, error) {
|
||||
func (s *NotificationService) UpdatePreferences(ctx context.Context, userID uint, req *UpdatePreferencesRequest) (*NotificationPreferencesResponse, error) {
|
||||
// B-12: Validate hour fields are in range 0-23
|
||||
hourFields := []struct {
|
||||
value *int
|
||||
@@ -213,7 +213,7 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
|
||||
}
|
||||
}
|
||||
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
|
||||
prefs.DailyDigestHour = req.DailyDigestHour
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).UpdatePreferences(prefs); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
@@ -268,14 +268,14 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
|
||||
// UpdateUserTimezone updates the user's timezone for background job calculations.
|
||||
// This is called automatically when the user makes API calls (e.g., fetching tasks).
|
||||
// The timezone should be an IANA timezone name (e.g., "America/Los_Angeles").
|
||||
func (s *NotificationService) UpdateUserTimezone(userID uint, timezone string) {
|
||||
func (s *NotificationService) UpdateUserTimezone(ctx context.Context, userID uint, timezone string) {
|
||||
// Validate timezone is a valid IANA name
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
return // Invalid timezone, skip silently
|
||||
}
|
||||
|
||||
// Get or create preferences and update timezone
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return // Skip silently on error
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func (s *NotificationService) UpdateUserTimezone(userID uint, timezone string) {
|
||||
// Only update if timezone changed (avoid unnecessary DB writes)
|
||||
if prefs.Timezone == nil || *prefs.Timezone != timezone {
|
||||
prefs.Timezone = &timezone
|
||||
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).UpdatePreferences(prefs); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Str("timezone", timezone).
|
||||
Msg("Failed to update user timezone in notification preferences")
|
||||
}
|
||||
@@ -293,27 +293,27 @@ func (s *NotificationService) UpdateUserTimezone(userID uint, timezone string) {
|
||||
// === Device Registration ===
|
||||
|
||||
// RegisterDevice registers a device for push notifications
|
||||
func (s *NotificationService) RegisterDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
func (s *NotificationService) RegisterDevice(ctx context.Context, userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
switch req.Platform {
|
||||
case push.PlatformIOS:
|
||||
return s.registerAPNSDevice(userID, req)
|
||||
return s.registerAPNSDevice(ctx, userID, req)
|
||||
case push.PlatformAndroid:
|
||||
return s.registerGCMDevice(userID, req)
|
||||
return s.registerGCMDevice(ctx, userID, req)
|
||||
default:
|
||||
return nil, apperrors.BadRequest("error.invalid_platform")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) registerAPNSDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
func (s *NotificationService) registerAPNSDevice(ctx context.Context, userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
// Check if device exists
|
||||
existing, err := s.notificationRepo.FindAPNSDeviceByToken(req.RegistrationID)
|
||||
existing, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByToken(req.RegistrationID)
|
||||
if err == nil {
|
||||
// Update existing device
|
||||
existing.UserID = &userID
|
||||
existing.Active = true
|
||||
existing.Name = req.Name
|
||||
existing.DeviceID = req.DeviceID
|
||||
if err := s.notificationRepo.UpdateAPNSDevice(existing); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).UpdateAPNSDevice(existing); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
return NewAPNSDeviceResponse(existing), nil
|
||||
@@ -327,22 +327,22 @@ func (s *NotificationService) registerAPNSDevice(userID uint, req *RegisterDevic
|
||||
RegistrationID: req.RegistrationID,
|
||||
Active: true,
|
||||
}
|
||||
if err := s.notificationRepo.CreateAPNSDevice(device); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).CreateAPNSDevice(device); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
return NewAPNSDeviceResponse(device), nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) registerGCMDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
func (s *NotificationService) registerGCMDevice(ctx context.Context, userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
// Check if device exists
|
||||
existing, err := s.notificationRepo.FindGCMDeviceByToken(req.RegistrationID)
|
||||
existing, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByToken(req.RegistrationID)
|
||||
if err == nil {
|
||||
// Update existing device
|
||||
existing.UserID = &userID
|
||||
existing.Active = true
|
||||
existing.Name = req.Name
|
||||
existing.DeviceID = req.DeviceID
|
||||
if err := s.notificationRepo.UpdateGCMDevice(existing); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).UpdateGCMDevice(existing); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
return NewGCMDeviceResponse(existing), nil
|
||||
@@ -357,20 +357,20 @@ func (s *NotificationService) registerGCMDevice(userID uint, req *RegisterDevice
|
||||
CloudMessageType: "FCM",
|
||||
Active: true,
|
||||
}
|
||||
if err := s.notificationRepo.CreateGCMDevice(device); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).CreateGCMDevice(device); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
return NewGCMDeviceResponse(device), nil
|
||||
}
|
||||
|
||||
// ListDevices lists all devices for a user
|
||||
func (s *NotificationService) ListDevices(userID uint) ([]DeviceResponse, error) {
|
||||
iosDevices, err := s.notificationRepo.FindAPNSDevicesByUser(userID)
|
||||
func (s *NotificationService) ListDevices(ctx context.Context, userID uint) ([]DeviceResponse, error) {
|
||||
iosDevices, err := s.notificationRepo.WithContext(ctx).FindAPNSDevicesByUser(userID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
androidDevices, err := s.notificationRepo.FindGCMDevicesByUser(userID)
|
||||
androidDevices, err := s.notificationRepo.WithContext(ctx).FindGCMDevicesByUser(userID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -387,10 +387,10 @@ func (s *NotificationService) ListDevices(userID uint) ([]DeviceResponse, error)
|
||||
|
||||
// DeleteDevice deactivates a device after verifying it belongs to the requesting user.
|
||||
// Without ownership verification, an attacker could deactivate push notifications for other users.
|
||||
func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userID uint) error {
|
||||
func (s *NotificationService) DeleteDevice(ctx context.Context, deviceID uint, platform string, userID uint) error {
|
||||
switch platform {
|
||||
case push.PlatformIOS:
|
||||
device, err := s.notificationRepo.FindAPNSDeviceByID(deviceID)
|
||||
device, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByID(deviceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperrors.NotFound("error.device_not_found")
|
||||
@@ -401,11 +401,11 @@ func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userI
|
||||
if device.UserID == nil || *device.UserID != userID {
|
||||
return apperrors.Forbidden("error.device_access_denied")
|
||||
}
|
||||
if err := s.notificationRepo.DeactivateAPNSDevice(deviceID); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).DeactivateAPNSDevice(deviceID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
case push.PlatformAndroid:
|
||||
device, err := s.notificationRepo.FindGCMDeviceByID(deviceID)
|
||||
device, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByID(deviceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperrors.NotFound("error.device_not_found")
|
||||
@@ -416,7 +416,7 @@ func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userI
|
||||
if device.UserID == nil || *device.UserID != userID {
|
||||
return apperrors.Forbidden("error.device_access_denied")
|
||||
}
|
||||
if err := s.notificationRepo.DeactivateGCMDevice(deviceID); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).DeactivateGCMDevice(deviceID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
default:
|
||||
@@ -426,10 +426,10 @@ func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userI
|
||||
}
|
||||
|
||||
// UnregisterDevice deactivates a device by its registration token
|
||||
func (s *NotificationService) UnregisterDevice(registrationID, platform string, userID uint) error {
|
||||
func (s *NotificationService) UnregisterDevice(ctx context.Context, registrationID, platform string, userID uint) error {
|
||||
switch platform {
|
||||
case push.PlatformIOS:
|
||||
device, err := s.notificationRepo.FindAPNSDeviceByToken(registrationID)
|
||||
device, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByToken(registrationID)
|
||||
if err != nil {
|
||||
return apperrors.NotFound("error.device_not_found")
|
||||
}
|
||||
@@ -437,11 +437,11 @@ func (s *NotificationService) UnregisterDevice(registrationID, platform string,
|
||||
if device.UserID == nil || *device.UserID != userID {
|
||||
return apperrors.NotFound("error.device_not_found")
|
||||
}
|
||||
if err := s.notificationRepo.DeactivateAPNSDevice(device.ID); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).DeactivateAPNSDevice(device.ID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
case push.PlatformAndroid:
|
||||
device, err := s.notificationRepo.FindGCMDeviceByToken(registrationID)
|
||||
device, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByToken(registrationID)
|
||||
if err != nil {
|
||||
return apperrors.NotFound("error.device_not_found")
|
||||
}
|
||||
@@ -449,7 +449,7 @@ func (s *NotificationService) UnregisterDevice(registrationID, platform string,
|
||||
if device.UserID == nil || *device.UserID != userID {
|
||||
return apperrors.NotFound("error.device_not_found")
|
||||
}
|
||||
if err := s.notificationRepo.DeactivateGCMDevice(device.ID); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).DeactivateGCMDevice(device.ID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
default:
|
||||
@@ -624,7 +624,7 @@ func (s *NotificationService) CreateAndSendTaskNotification(
|
||||
task *models.Task,
|
||||
) error {
|
||||
// Check user notification preferences
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
@@ -662,12 +662,12 @@ func (s *NotificationService) CreateAndSendTaskNotification(
|
||||
TaskID: &task.ID,
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.Create(notification); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).Create(notification); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get device tokens
|
||||
iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID)
|
||||
iosTokens, androidTokens, err := s.notificationRepo.WithContext(ctx).GetActiveTokensForUser(userID)
|
||||
if err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
@@ -691,12 +691,12 @@ func (s *NotificationService) CreateAndSendTaskNotification(
|
||||
if s.pushClient != nil {
|
||||
err = s.pushClient.SendActionableNotification(ctx, iosTokens, androidTokens, title, body, pushData, iosCategoryID)
|
||||
if err != nil {
|
||||
s.notificationRepo.SetError(notification.ID, err.Error())
|
||||
s.notificationRepo.WithContext(ctx).SetError(notification.ID, err.Error())
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.MarkAsSent(notification.ID); err != nil {
|
||||
if err := s.notificationRepo.WithContext(ctx).MarkAsSent(notification.ID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user