Files
honeyDueAPI/internal/services/contractor_service.go
T
Trey t e881d37de0
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
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>
2026-04-25 16:26:21 -05:00

343 lines
11 KiB
Go

package services
import (
"context"
"errors"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// Deprecated: Use apperrors.NotFound("error.contractor_not_found") instead
// var (
// ErrContractorNotFound = errors.New("contractor not found")
// ErrContractorAccessDenied = errors.New("you do not have access to this contractor")
// )
// ContractorService handles contractor business logic
type ContractorService struct {
contractorRepo *repositories.ContractorRepository
residenceRepo *repositories.ResidenceRepository
}
// NewContractorService creates a new contractor service
func NewContractorService(contractorRepo *repositories.ContractorRepository, residenceRepo *repositories.ResidenceRepository) *ContractorService {
return &ContractorService{
contractorRepo: contractorRepo,
residenceRepo: residenceRepo,
}
}
// GetContractor gets a contractor by ID with access check
func (s *ContractorService) GetContractor(ctx context.Context, contractorID, userID uint) (*responses.ContractorResponse, error) {
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(ctx, contractor, userID) {
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
resp := responses.NewContractorResponse(contractor)
return &resp, nil
}
// hasContractorAccess checks if user has access to a contractor
// Access rules:
// - If contractor has no residence: only the creator has access
// - If contractor has a residence: all users with access to that residence
func (s *ContractorService) hasContractorAccess(ctx context.Context, contractor *models.Contractor, userID uint) bool {
if contractor.ResidenceID == nil {
// Personal contractor - only creator has access
return contractor.CreatedByID == userID
}
// Residence contractor - check residence access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(*contractor.ResidenceID, userID)
if err != nil {
return false
}
return hasAccess
}
// ListContractors lists all contractors accessible to a user
func (s *ContractorService) ListContractors(ctx context.Context, userID uint) ([]responses.ContractorResponse, error) {
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
// FindByUser now handles both personal and residence contractors
contractors, err := s.contractorRepo.WithContext(ctx).FindByUser(userID, residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
return responses.NewContractorListResponse(contractors), nil
}
// CreateContractor creates a new contractor
func (s *ContractorService) CreateContractor(ctx context.Context, req *requests.CreateContractorRequest, userID uint) (*responses.ContractorResponse, error) {
// If residence is provided, check access
if req.ResidenceID != nil {
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(*req.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
}
isFavorite := false
if req.IsFavorite != nil {
isFavorite = *req.IsFavorite
}
contractor := &models.Contractor{
ResidenceID: req.ResidenceID,
CreatedByID: userID,
Name: req.Name,
Company: req.Company,
Phone: req.Phone,
Email: req.Email,
Website: req.Website,
Notes: req.Notes,
StreetAddress: req.StreetAddress,
City: req.City,
StateProvince: req.StateProvince,
PostalCode: req.PostalCode,
Rating: req.Rating,
IsFavorite: isFavorite,
IsActive: true,
}
if err := s.contractorRepo.WithContext(ctx).Create(contractor); err != nil {
return nil, apperrors.Internal(err)
}
// Set specialties if provided
if len(req.SpecialtyIDs) > 0 {
if err := s.contractorRepo.WithContext(ctx).SetSpecialties(contractor.ID, req.SpecialtyIDs); err != nil {
return nil, apperrors.Internal(err)
}
}
// Reload with relations
contractor, reloadErr := s.contractorRepo.WithContext(ctx).FindByID(contractor.ID)
if reloadErr != nil {
return nil, apperrors.Internal(reloadErr)
}
resp := responses.NewContractorResponse(contractor)
return &resp, nil
}
// UpdateContractor updates a contractor
func (s *ContractorService) UpdateContractor(ctx context.Context, contractorID, userID uint, req *requests.UpdateContractorRequest) (*responses.ContractorResponse, error) {
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(ctx, contractor, userID) {
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
// Apply updates
if req.Name != nil {
contractor.Name = *req.Name
}
if req.Company != nil {
contractor.Company = *req.Company
}
if req.Phone != nil {
contractor.Phone = *req.Phone
}
if req.Email != nil {
contractor.Email = *req.Email
}
if req.Website != nil {
contractor.Website = *req.Website
}
if req.Notes != nil {
contractor.Notes = *req.Notes
}
if req.StreetAddress != nil {
contractor.StreetAddress = *req.StreetAddress
}
if req.City != nil {
contractor.City = *req.City
}
if req.StateProvince != nil {
contractor.StateProvince = *req.StateProvince
}
if req.PostalCode != nil {
contractor.PostalCode = *req.PostalCode
}
if req.Rating != nil {
contractor.Rating = req.Rating
}
if req.IsFavorite != nil {
contractor.IsFavorite = *req.IsFavorite
}
// If residence_id is provided, verify the user has access to the NEW residence.
// This prevents an attacker from reassigning a contractor to someone else's residence.
if req.ResidenceID != nil {
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(*req.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
}
// If residence_id is not sent in the request (nil), it means the user
// removed the residence association - contractor becomes personal
contractor.ResidenceID = req.ResidenceID
if err := s.contractorRepo.WithContext(ctx).Update(contractor); err != nil {
return nil, apperrors.Internal(err)
}
// Update specialties if provided
if req.SpecialtyIDs != nil {
if err := s.contractorRepo.WithContext(ctx).SetSpecialties(contractorID, req.SpecialtyIDs); err != nil {
return nil, apperrors.Internal(err)
}
}
// Reload
contractor, err = s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewContractorResponse(contractor)
return &resp, nil
}
// DeleteContractor soft-deletes a contractor
func (s *ContractorService) DeleteContractor(ctx context.Context, contractorID, userID uint) error {
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.NotFound("error.contractor_not_found")
}
return apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(ctx, contractor, userID) {
return apperrors.Forbidden("error.contractor_access_denied")
}
if err := s.contractorRepo.WithContext(ctx).Delete(contractorID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// ToggleFavorite toggles the favorite status of a contractor and returns the updated contractor
func (s *ContractorService) ToggleFavorite(ctx context.Context, contractorID, userID uint) (*responses.ContractorResponse, error) {
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(ctx, contractor, userID) {
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
_, err = s.contractorRepo.WithContext(ctx).ToggleFavorite(contractorID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Re-fetch the contractor to get the updated state with all relations
contractor, err = s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewContractorResponse(contractor)
return &resp, nil
}
// GetContractorTasks gets all tasks for a contractor
func (s *ContractorService) GetContractorTasks(ctx context.Context, contractorID, userID uint) ([]responses.TaskResponse, error) {
contractor, err := s.contractorRepo.WithContext(ctx).FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(ctx, contractor, userID) {
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
tasks, err := s.contractorRepo.WithContext(ctx).GetTasksForContractor(contractorID)
if err != nil {
return nil, apperrors.Internal(err)
}
return responses.NewTaskListResponse(tasks), nil
}
// ListContractorsByResidence lists all contractors for a specific residence
func (s *ContractorService) ListContractorsByResidence(ctx context.Context, residenceID, userID uint) ([]responses.ContractorResponse, error) {
// Check user has access to the residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
contractors, err := s.contractorRepo.WithContext(ctx).FindByResidence(residenceID)
if err != nil {
return nil, apperrors.Internal(err)
}
return responses.NewContractorListResponse(contractors), nil
}
// GetSpecialties returns all contractor specialties
func (s *ContractorService) GetSpecialties(ctx context.Context) ([]responses.ContractorSpecialtyResponse, error) {
specialties, err := s.contractorRepo.WithContext(ctx).GetAllSpecialties()
if err != nil {
return nil, apperrors.Internal(err)
}
result := make([]responses.ContractorSpecialtyResponse, len(specialties))
for i, sp := range specialties {
result[i] = responses.NewContractorSpecialtyResponse(&sp)
}
return result, nil
}