Files
honeyDueAPI/internal/repositories/contractor_repo.go
Trey t 7690f07a2b Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:48:01 -06:00

192 lines
6.1 KiB
Go

package repositories
import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
)
// ContractorRepository handles database operations for contractors
type ContractorRepository struct {
db *gorm.DB
}
// NewContractorRepository creates a new contractor repository
func NewContractorRepository(db *gorm.DB) *ContractorRepository {
return &ContractorRepository{db: db}
}
// FindByID finds a contractor by ID with preloaded relations
func (r *ContractorRepository) FindByID(id uint) (*models.Contractor, error) {
var contractor models.Contractor
err := r.db.Preload("CreatedBy").
Preload("Specialties").
Preload("Tasks").
Where("id = ? AND is_active = ?", id, true).
First(&contractor).Error
if err != nil {
return nil, err
}
return &contractor, nil
}
// FindByResidence finds all contractors for a residence
func (r *ContractorRepository) FindByResidence(residenceID uint) ([]models.Contractor, error) {
var contractors []models.Contractor
err := r.db.Preload("CreatedBy").
Preload("Specialties").
Where("residence_id = ? AND is_active = ?", residenceID, true).
Order("is_favorite DESC, name ASC").
Find(&contractors).Error
return contractors, err
}
// FindByUser finds all contractors accessible to a user
// Returns contractors that either:
// 1. Have no residence (personal contractors) AND were created by the user
// 2. Belong to a residence the user has access to
func (r *ContractorRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.Contractor, error) {
var contractors []models.Contractor
query := r.db.Preload("CreatedBy").
Preload("Specialties").
Preload("Residence").
Where("is_active = ?", true)
if len(residenceIDs) > 0 {
// Personal contractors (no residence, created by user) OR residence contractors
query = query.Where(
"(residence_id IS NULL AND created_by_id = ?) OR (residence_id IN ?)",
userID, residenceIDs,
)
} else {
// Only personal contractors
query = query.Where("residence_id IS NULL AND created_by_id = ?", userID)
}
err := query.Order("is_favorite DESC, name ASC").Limit(500).Find(&contractors).Error
return contractors, err
}
// Create creates a new contractor
func (r *ContractorRepository) Create(contractor *models.Contractor) error {
return r.db.Create(contractor).Error
}
// Update updates a contractor
// Uses Omit to exclude associations that could interfere with Save
func (r *ContractorRepository) Update(contractor *models.Contractor) error {
return r.db.Omit("CreatedBy", "Specialties", "Tasks", "Residence").Save(contractor).Error
}
// Delete soft-deletes a contractor
func (r *ContractorRepository) Delete(id uint) error {
return r.db.Model(&models.Contractor{}).
Where("id = ?", id).
Update("is_active", false).Error
}
// ToggleFavorite toggles the favorite status of a contractor atomically.
// Uses a single UPDATE with NOT to avoid read-then-write race conditions.
// Only toggles active contractors to prevent toggling soft-deleted records.
func (r *ContractorRepository) ToggleFavorite(id uint) (bool, error) {
var newStatus bool
err := r.db.Transaction(func(tx *gorm.DB) error {
// Atomic toggle: SET is_favorite = NOT is_favorite for active contractors only
result := tx.Model(&models.Contractor{}).
Where("id = ? AND is_active = ?", id, true).
Update("is_favorite", gorm.Expr("NOT is_favorite"))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
// Read back the new value within the same transaction
var contractor models.Contractor
if err := tx.Select("is_favorite").First(&contractor, id).Error; err != nil {
return err
}
newStatus = contractor.IsFavorite
return nil
})
return newStatus, err
}
// GetTasksForContractor gets all tasks associated with a contractor
func (r *ContractorRepository) GetTasksForContractor(contractorID uint) ([]models.Task, error) {
var tasks []models.Task
err := r.db.Preload("Category").
Preload("Priority").
Where("contractor_id = ?", contractorID).
Order("due_date ASC NULLS LAST").
Find(&tasks).Error
return tasks, err
}
// SetSpecialties sets the specialties for a contractor
func (r *ContractorRepository) SetSpecialties(contractorID uint, specialtyIDs []uint) error {
var contractor models.Contractor
if err := r.db.First(&contractor, contractorID).Error; err != nil {
return err
}
// Clear existing specialties
if err := r.db.Model(&contractor).Association("Specialties").Clear(); err != nil {
return err
}
if len(specialtyIDs) == 0 {
return nil
}
// Add new specialties
var specialties []models.ContractorSpecialty
if err := r.db.Where("id IN ?", specialtyIDs).Find(&specialties).Error; err != nil {
return err
}
return r.db.Model(&contractor).Association("Specialties").Append(specialties)
}
// CountByResidence counts contractors in a residence
func (r *ContractorRepository) CountByResidence(residenceID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Contractor{}).
Where("residence_id = ? AND is_active = ?", residenceID, true).
Count(&count).Error
return count, err
}
// CountByResidenceIDs counts all active contractors across multiple residences in a single query.
// Returns the total count of active contractors for the given residence IDs.
func (r *ContractorRepository) CountByResidenceIDs(residenceIDs []uint) (int64, error) {
if len(residenceIDs) == 0 {
return 0, nil
}
var count int64
err := r.db.Model(&models.Contractor{}).
Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
Count(&count).Error
return count, err
}
// === Specialty Operations ===
// GetAllSpecialties returns all contractor specialties
func (r *ContractorRepository) GetAllSpecialties() ([]models.ContractorSpecialty, error) {
var specialties []models.ContractorSpecialty
err := r.db.Order("display_order, name").Find(&specialties).Error
return specialties, err
}
// FindSpecialtyByID finds a specialty by ID
func (r *ContractorRepository) FindSpecialtyByID(id uint) (*models.ContractorSpecialty, error) {
var specialty models.ContractorSpecialty
err := r.db.First(&specialty, id).Error
if err != nil {
return nil, err
}
return &specialty, nil
}