Migrate TaskService + ResidenceService to ctx-aware repos
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 TaskService and ResidenceService now takes
ctx context.Context as the first arg and routes its repo calls through
.WithContext(ctx). With otelgorm registered, this means every API
endpoint backed by these two services produces a flame graph in Jaeger
where the SQL spans nest under the parent HTTP request span — instead
of appearing as orphaned queries.

Endpoints now fully traced (HTTP → service → SQL):
- GET    /api/tasks/                       (already shipped)
- GET    /api/tasks/by-residence/:id/      (already shipped)
- GET    /api/tasks/:id/
- POST   /api/tasks/
- POST   /api/tasks/bulk/
- PUT    /api/tasks/:id/
- DELETE /api/tasks/:id/
- POST   /api/tasks/:id/in-progress/
- POST   /api/tasks/:id/cancel/
- POST   /api/tasks/:id/uncancel/
- POST   /api/tasks/:id/archive/
- POST   /api/tasks/:id/unarchive/
- POST   /api/tasks/:id/complete/
- POST   /api/tasks/:id/quick-complete/
- GET    /api/tasks/completions/* (CRUD)
- GET    /api/static_data/ (categories, priorities, frequencies)
- GET    /api/residences/
- GET    /api/residences/my/
- GET    /api/residences/summary/
- GET    /api/residences/:id/
- POST   /api/residences/
- PUT    /api/residences/:id/
- DELETE /api/residences/:id/
- Share-code + member management endpoints
- GET    /api/residences/:id/report/

Mechanical work: ~50 method signatures, ~80 handler call sites,
~25 test call sites updated. Internal sendTaskCompletedNotification
helper also takes ctx so background notification SQL nests correctly.

The remaining services (ContractorService, DocumentService,
AuthService, NotificationService, SubscriptionService) follow the same
pattern; they continue to emit untraced SQL until migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-25 16:04:01 -05:00
parent 3f5bf21e09
commit 65a9aae4e5
9 changed files with 382 additions and 378 deletions
+54 -53
View File
@@ -1,6 +1,7 @@
package services
import (
"context"
"errors"
"time"
@@ -60,9 +61,9 @@ func (s *ResidenceService) SetSubscriptionService(subService *SubscriptionServic
// GetResidence gets a residence by ID with access check.
// The `now` parameter is used for timezone-aware completion summary aggregation.
func (s *ResidenceService) GetResidence(residenceID, userID uint, now time.Time) (*responses.ResidenceResponse, error) {
func (s *ResidenceService) GetResidence(ctx context.Context, residenceID, userID uint, now time.Time) (*responses.ResidenceResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -70,7 +71,7 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint, now time.Time)
return nil, apperrors.Forbidden("error.residence_access_denied")
}
residence, err := s.residenceRepo.FindByID(residenceID)
residence, err := s.residenceRepo.WithContext(ctx).FindByID(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.residence_not_found")
@@ -82,7 +83,7 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint, now time.Time)
// Attach completion summary (honeycomb grid data)
if s.taskRepo != nil {
summary, err := s.taskRepo.GetCompletionSummary(residenceID, now, 10)
summary, err := s.taskRepo.WithContext(ctx).GetCompletionSummary(residenceID, now, 10)
if err != nil {
log.Warn().Err(err).Uint("residence_id", residenceID).Msg("Failed to fetch completion summary")
} else {
@@ -94,8 +95,8 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint, now time.Time)
}
// ListResidences lists all residences accessible to a user
func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
func (s *ResidenceService) ListResidences(ctx context.Context, userID uint) ([]responses.ResidenceResponse, error) {
residences, err := s.residenceRepo.WithContext(ctx).FindByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -109,8 +110,8 @@ func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceRes
//
// NOTE: Summary statistics (TotalTasks, TotalOverdue, etc.) are calculated client-side
// from kanban data for performance. Only per-residence OverdueCount is returned from the server.
func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*responses.MyResidencesResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
func (s *ResidenceService) GetMyResidences(ctx context.Context, userID uint, now time.Time) (*responses.MyResidencesResponse, error) {
residences, err := s.residenceRepo.WithContext(ctx).FindByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -124,7 +125,7 @@ func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*respons
residenceIDs[i] = r.ID
}
overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs, now)
overdueCounts, err := s.taskRepo.WithContext(ctx).GetOverdueCountByResidence(residenceIDs, now)
if err == nil && overdueCounts != nil {
for i := range residenceResponses {
if count, ok := overdueCounts[residenceResponses[i].ID]; ok {
@@ -134,7 +135,7 @@ func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*respons
}
// P-01: Batch fetch completion summaries in 2 queries total instead of 2*N
summaries, err := s.taskRepo.GetBatchCompletionSummaries(residenceIDs, now, 10)
summaries, err := s.taskRepo.WithContext(ctx).GetBatchCompletionSummaries(residenceIDs, now, 10)
if err != nil {
log.Warn().Err(err).Msg("Failed to fetch batch completion summaries")
} else {
@@ -157,9 +158,9 @@ func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*respons
// DEPRECATED: Summary statistics are now calculated client-side from kanban data.
// This endpoint only returns TotalResidences; other fields will be zero.
// Clients should use calculateSummaryFromKanban() instead.
func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.TotalSummary, error) {
func (s *ResidenceService) GetSummary(ctx context.Context, userID uint, now time.Time) (*responses.TotalSummary, error) {
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -182,7 +183,7 @@ func (s *ResidenceService) getSummaryForUser(_ uint) responses.TotalSummary {
}
// CreateResidence creates a new residence and returns it with updated summary
func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceWithSummaryResponse, error) {
func (s *ResidenceService) CreateResidence(ctx context.Context, req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceWithSummaryResponse, error) {
// Check subscription tier limits (if subscription service is wired up)
if s.subscriptionService != nil {
if err := s.subscriptionService.CheckLimit(ownerID, "properties"); err != nil {
@@ -253,12 +254,12 @@ func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest,
residence.HasAttic = *req.HasAttic
}
if err := s.residenceRepo.Create(residence); err != nil {
if err := s.residenceRepo.WithContext(ctx).Create(residence); err != nil {
return nil, apperrors.Internal(err)
}
// Reload with relations
residence, err := s.residenceRepo.FindByID(residence.ID)
residence, err := s.residenceRepo.WithContext(ctx).FindByID(residence.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -273,9 +274,9 @@ func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest,
}
// UpdateResidence updates a residence and returns it with updated summary
func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceWithSummaryResponse, error) {
func (s *ResidenceService) UpdateResidence(ctx context.Context, residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceWithSummaryResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
isOwner, err := s.residenceRepo.WithContext(ctx).IsOwner(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -283,7 +284,7 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques
return nil, apperrors.Forbidden("error.not_residence_owner")
}
residence, err := s.residenceRepo.FindByID(residenceID)
residence, err := s.residenceRepo.WithContext(ctx).FindByID(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.residence_not_found")
@@ -388,12 +389,12 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques
residence.LandscapingType = req.LandscapingType
}
if err := s.residenceRepo.Update(residence); err != nil {
if err := s.residenceRepo.WithContext(ctx).Update(residence); err != nil {
return nil, apperrors.Internal(err)
}
// Reload with relations
residence, err = s.residenceRepo.FindByID(residence.ID)
residence, err = s.residenceRepo.WithContext(ctx).FindByID(residence.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -408,9 +409,9 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques
}
// DeleteResidence soft-deletes a residence and returns updated summary
func (s *ResidenceService) DeleteResidence(residenceID, userID uint) (*responses.ResidenceDeleteWithSummaryResponse, error) {
func (s *ResidenceService) DeleteResidence(ctx context.Context, residenceID, userID uint) (*responses.ResidenceDeleteWithSummaryResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
isOwner, err := s.residenceRepo.WithContext(ctx).IsOwner(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -418,7 +419,7 @@ func (s *ResidenceService) DeleteResidence(residenceID, userID uint) (*responses
return nil, apperrors.Forbidden("error.not_residence_owner")
}
if err := s.residenceRepo.Delete(residenceID); err != nil {
if err := s.residenceRepo.WithContext(ctx).Delete(residenceID); err != nil {
return nil, apperrors.Internal(err)
}
@@ -432,9 +433,9 @@ func (s *ResidenceService) DeleteResidence(residenceID, userID uint) (*responses
}
// GenerateShareCode generates a new share code for a residence
func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresInHours int) (*responses.GenerateShareCodeResponse, error) {
func (s *ResidenceService) GenerateShareCode(ctx context.Context, residenceID, userID uint, expiresInHours int) (*responses.GenerateShareCodeResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
isOwner, err := s.residenceRepo.WithContext(ctx).IsOwner(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -447,7 +448,7 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn
expiresInHours = 24
}
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
shareCode, err := s.residenceRepo.WithContext(ctx).CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -459,9 +460,9 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn
}
// GetShareCode retrieves the active share code for a residence (if any)
func (s *ResidenceService) GetShareCode(residenceID, userID uint) (*responses.ShareCodeResponse, error) {
func (s *ResidenceService) GetShareCode(ctx context.Context, residenceID, userID uint) (*responses.ShareCodeResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -469,7 +470,7 @@ func (s *ResidenceService) GetShareCode(residenceID, userID uint) (*responses.Sh
return nil, apperrors.Forbidden("error.residence_access_denied")
}
shareCode, err := s.residenceRepo.GetActiveShareCode(residenceID)
shareCode, err := s.residenceRepo.WithContext(ctx).GetActiveShareCode(residenceID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -482,9 +483,9 @@ func (s *ResidenceService) GetShareCode(residenceID, userID uint) (*responses.Sh
}
// GenerateSharePackage generates a share code and returns package metadata for .honeydue file
func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expiresInHours int) (*responses.SharePackageResponse, error) {
func (s *ResidenceService) GenerateSharePackage(ctx context.Context, residenceID, userID uint, expiresInHours int) (*responses.SharePackageResponse, error) {
// Check ownership (only owners can share residences)
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
isOwner, err := s.residenceRepo.WithContext(ctx).IsOwner(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -493,13 +494,13 @@ func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expire
}
// Get residence details for the package
residence, err := s.residenceRepo.FindByID(residenceID)
residence, err := s.residenceRepo.WithContext(ctx).FindByID(residenceID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Get the user who's sharing
user, err := s.userRepo.FindByID(userID)
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -510,7 +511,7 @@ func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expire
}
// Generate the share code
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
shareCode, err := s.residenceRepo.WithContext(ctx).CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -524,9 +525,9 @@ func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expire
}
// JoinWithCode allows a user to join a residence using a share code
func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.JoinResidenceResponse, error) {
func (s *ResidenceService) JoinWithCode(ctx context.Context, code string, userID uint) (*responses.JoinResidenceResponse, error) {
// Find the share code
shareCode, err := s.residenceRepo.FindShareCodeByCode(code)
shareCode, err := s.residenceRepo.WithContext(ctx).FindShareCodeByCode(code)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.share_code_invalid")
@@ -535,7 +536,7 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo
}
// Check if already a member
hasAccess, err := s.residenceRepo.HasAccess(shareCode.ResidenceID, userID)
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(shareCode.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -544,19 +545,19 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo
}
// Add user to residence
if err := s.residenceRepo.AddUser(shareCode.ResidenceID, userID); err != nil {
if err := s.residenceRepo.WithContext(ctx).AddUser(shareCode.ResidenceID, userID); err != nil {
return nil, apperrors.Internal(err)
}
// Mark share code as used (one-time use)
if err := s.residenceRepo.DeactivateShareCode(shareCode.ID); err != nil {
if err := s.residenceRepo.WithContext(ctx).DeactivateShareCode(shareCode.ID); err != nil {
// Log the error but don't fail the join - the user has already been added
// The code will just be usable by others until it expires
log.Error().Err(err).Uint("code_id", shareCode.ID).Msg("Failed to deactivate share code after join")
}
// Get the residence with full details
residence, err := s.residenceRepo.FindByID(shareCode.ResidenceID)
residence, err := s.residenceRepo.WithContext(ctx).FindByID(shareCode.ResidenceID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -572,9 +573,9 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo
}
// GetResidenceUsers returns all users with access to a residence
func (s *ResidenceService) GetResidenceUsers(residenceID, userID uint) ([]responses.ResidenceUserResponse, error) {
func (s *ResidenceService) GetResidenceUsers(ctx context.Context, residenceID, userID uint) ([]responses.ResidenceUserResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -582,7 +583,7 @@ func (s *ResidenceService) GetResidenceUsers(residenceID, userID uint) ([]respon
return nil, apperrors.Forbidden("error.residence_access_denied")
}
users, err := s.residenceRepo.GetResidenceUsers(residenceID)
users, err := s.residenceRepo.WithContext(ctx).GetResidenceUsers(residenceID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -596,9 +597,9 @@ func (s *ResidenceService) GetResidenceUsers(residenceID, userID uint) ([]respon
}
// RemoveUser removes a user from a residence (owner only)
func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUserID uint) error {
func (s *ResidenceService) RemoveUser(ctx context.Context, residenceID, userIDToRemove, requestingUserID uint) error {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, requestingUserID)
isOwner, err := s.residenceRepo.WithContext(ctx).IsOwner(residenceID, requestingUserID)
if err != nil {
return apperrors.Internal(err)
}
@@ -612,7 +613,7 @@ func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUse
}
// Check if the residence exists
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
residence, err := s.residenceRepo.WithContext(ctx).FindByIDSimple(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.NotFound("error.residence_not_found")
@@ -625,7 +626,7 @@ func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUse
return apperrors.BadRequest("error.cannot_remove_owner")
}
if err := s.residenceRepo.RemoveUser(residenceID, userIDToRemove); err != nil {
if err := s.residenceRepo.WithContext(ctx).RemoveUser(residenceID, userIDToRemove); err != nil {
return apperrors.Internal(err)
}
@@ -633,8 +634,8 @@ func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUse
}
// GetResidenceTypes returns all residence types
func (s *ResidenceService) GetResidenceTypes() ([]responses.ResidenceTypeResponse, error) {
types, err := s.residenceRepo.GetAllResidenceTypes()
func (s *ResidenceService) GetResidenceTypes(ctx context.Context) ([]responses.ResidenceTypeResponse, error) {
types, err := s.residenceRepo.WithContext(ctx).GetAllResidenceTypes()
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -674,9 +675,9 @@ type TasksReportResponse struct {
}
// GenerateTasksReport generates a report of all tasks for a residence
func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*TasksReportResponse, error) {
func (s *ResidenceService) GenerateTasksReport(ctx context.Context, residenceID, userID uint) (*TasksReportResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
@@ -685,7 +686,7 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
}
// Get residence details
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
residence, err := s.residenceRepo.WithContext(ctx).FindByIDSimple(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.residence_not_found")
@@ -694,7 +695,7 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
}
// Get all tasks for the residence
tasks, err := s.residenceRepo.GetTasksForReport(residenceID)
tasks, err := s.residenceRepo.WithContext(ctx).GetTasksForReport(residenceID)
if err != nil {
return nil, apperrors.Internal(err)
}