Files
honeyDueAPI/internal/services/contractor_service.go
T
Trey t 88fb1751c7
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
Cut /api/tasks/ p99 from ~2500ms toward ~150-300ms
Stack of optimizations against the same Hetzner→Neon transatlantic link.
The trace revealed every visible ms was network/proxy overhead — DB
execution itself is sub-millisecond per query (verified via EXPLAIN
ANALYZE: index scans on every hot path).

Connection layer:
- DB_HOST → Neon pooler endpoint (-pooler suffix). PgBouncer
  transaction-mode keeps backend Postgres connections warm so we no
  longer pay the ~110ms Postgres-startup RTT on cold queries.
- GORM pool tuned: MaxIdleConns 10→20, MaxLifetime 600s→1800s,
  MaxIdleTime added (default 0 = never close idle).
- Eager pool warm-up at boot via parallel pings — first user request
  no longer pays the ~440ms TCP+TLS+startup handshake.
- Redis maxmemory-policy noeviction → allkeys-lru. Cache writes will
  evict cold keys instead of erroring at the 256MB limit.

Auth layer:
- TokenCacheTTL 5min → 1 hour (Redis token cache).
- UserCacheTTL 30s → 5min (in-memory User cache, per pod).
- UserCache gains a 5,000-entry LRU cap so a flood of unique users
  can't blow up pod RSS. ~5MB worst-case per pod.
- Token + user lookup collapsed from 2 GORM Preload queries into a
  single INNER JOIN. Saves 1 RTT per cold-cache request.
- Auth middleware's m.db.* now use db.WithContext(ctx) so the SQL
  spans nest under the parent HTTP request in Jaeger.

Service layer:
- TaskService.ListTasks: replaced two-step
  FindResidenceIDsByUser → GetKanbanDataForMultipleResidences
  with a single GetKanbanDataForUser that uses a Postgres subquery
  for residence-access. One round-trip instead of two.
- New CacheService residence-IDs cache: \"residence_ids_user:<id>\"
  with 5-min TTL. Wired into Task/Residence/Contractor/Document
  services for the four hot read paths that need this list.
- Cache invalidation on every relevant mutation: CreateResidence,
  DeleteResidence, JoinWithCode, RemoveUser. DeleteResidence
  invalidates every member of the residence, not just the owner.

What this stacks up to (Hetzner→Neon, before US migration):
  Path                                 Before        After (target)
  Cache-warm authed read               ~800ms        ~100-200ms
  Cache-cold authed read (1st in 1hr)  ~2500ms       ~500-700ms
  First request after deploy           ~2500ms       ~700-900ms

The endgame US-region migration on top of this gets us to ~30-50ms
warm-cache, but we're shippable at ~150ms warm right now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:13:50 -05:00

349 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
cache *CacheService
}
// NewContractorService creates a new contractor service
func NewContractorService(contractorRepo *repositories.ContractorRepository, residenceRepo *repositories.ResidenceRepository) *ContractorService {
return &ContractorService{
contractorRepo: contractorRepo,
residenceRepo: residenceRepo,
}
}
// SetCacheService wires Redis caching for residence-ID lookups.
func (s *ContractorService) SetCacheService(cache *CacheService) {
s.cache = cache
}
// 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 := cachedResidenceIDsForUser(ctx, s.cache, s.residenceRepo, 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
}