Security: - Replace all binding: tags with validate: + c.Validate() in admin handlers - Add rate limiting to auth endpoints (login, register, password reset) - Add security headers (HSTS, XSS protection, nosniff, frame options) - Wire Google Pub/Sub token verification into webhook handler - Replace ParseUnverified with proper OIDC/JWKS key verification - Verify inner Apple JWS signatures in webhook handler - Add io.LimitReader (1MB) to all webhook body reads - Add ownership verification to file deletion - Move hardcoded admin credentials to env vars - Add uniqueIndex to User.Email - Hide ConfirmationCode from JSON serialization - Mask confirmation codes in admin responses - Use http.DetectContentType for upload validation - Fix path traversal in storage service - Replace os.Getenv with Viper in stripe service - Sanitize Redis URLs before logging - Separate DEBUG_FIXED_CODES from DEBUG flag - Reject weak SECRET_KEY in production - Add host check on /_next/* proxy routes - Use explicit localhost CORS origins in debug mode - Replace err.Error() with generic messages in all admin error responses Critical fixes: - Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth - Fix user_customuser -> auth_user table names in raw SQL - Fix dashboard verified query to use UserProfile model - Add escapeLikeWildcards() to prevent SQL wildcard injection Bug fixes: - Add bounds checks for days/expiring_soon query params (1-3650) - Add receipt_data/transaction_id empty-check to RestoreSubscription - Change Active bool -> *bool in device handler - Check all unchecked GORM/FindByIDWithProfile errors - Add validation for notification hour fields (0-23) - Add max=10000 validation on task description updates Transactions & data integrity: - Wrap registration flow in transaction - Wrap QuickComplete in transaction - Move image creation inside completion transaction - Wrap SetSpecialties in transaction - Wrap GetOrCreateToken in transaction - Wrap completion+image deletion in transaction Performance: - Batch completion summaries (2 queries vs 2N) - Reuse single http.Client in IAP validation - Cache dashboard counts (30s TTL) - Batch COUNT queries in admin user list - Add Limit(500) to document queries - Add reminder_stage+due_date filters to reminder queries - Parse AllowedTypes once at init - In-memory user cache in auth middleware (30s TTL) - Timezone change detection cache - Optimize P95 with per-endpoint sorted buffers - Replace crypto/md5 with hash/fnv for ETags Code quality: - Add sync.Once to all monitoring Stop()/Close() methods - Replace 8 fmt.Printf with zerolog in auth service - Log previously discarded errors - Standardize delete response shapes - Route hardcoded English through i18n - Remove FileURL from DocumentResponse (keep MediaURL only) - Thread user timezone through kanban board responses - Initialize empty slices to prevent null JSON - Extract shared field map for task Update/UpdateTx - Delete unused SoftDeleteModel, min(), formatCron, legacy handlers Worker & jobs: - Wire Asynq email infrastructure into worker - Register HandleReminderLogCleanup with daily 3AM cron - Use per-user timezone in HandleSmartReminder - Replace direct DB queries with repository calls - Delete legacy reminder handlers (~200 lines) - Delete unused task type constants Dependencies: - Replace archived jung-kurt/gofpdf with go-pdf/fpdf - Replace unmaintained gomail.v2 with wneessen/go-mail - Add TODO for Echo jwt v3 transitive dep removal Test infrastructure: - Fix MakeRequest/SeedLookupData error handling - Replace os.Exit(0) with t.Skip() in scope/consistency tests - Add 11 new FCM v1 tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
196 lines
6.3 KiB
Go
196 lines
6.3 KiB
Go
package repositories
|
|
|
|
import (
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/honeydue-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.
|
|
// Wrapped in a transaction so that clearing existing specialties and
|
|
// appending new ones are atomic -- a failure in either step rolls back both.
|
|
func (r *ContractorRepository) SetSpecialties(contractorID uint, specialtyIDs []uint) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
var contractor models.Contractor
|
|
if err := tx.First(&contractor, contractorID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clear existing specialties
|
|
if err := tx.Model(&contractor).Association("Specialties").Clear(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(specialtyIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Add new specialties
|
|
var specialties []models.ContractorSpecialty
|
|
if err := tx.Where("id IN ?", specialtyIDs).Find(&specialties).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.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
|
|
}
|