bc3da007db
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod, overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api and honeydue-worker. otelecho.Middleware opens a span per HTTP request. Step 2 — Manual spans: storage_service.Upload now takes ctx and emits storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket, result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token spans with topic, status_code, reason. Asynq middleware emits asynq.handle:<task_type> per job with retry/payload attrs and records asynq_job_duration_seconds. Step 3 — Database: otelgorm plugin registered in database.Connect, so any SQL emitted via db.WithContext(ctx) attaches to the request span. Every repository now exposes WithContext(ctx) *XRepository as the migration helper. TaskService.ListTasks and GetTasksByResidence are migrated end-to-end (ctx threaded through handler → service → repo); remaining services adopt the same pattern incrementally — pre-migration methods still emit untraced SQL via the unchanged db field. OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env → honeydue-secrets → api+worker Deployments via secretKeyRef (optional). 02-setup-secrets.sh sources them from prod.env on next run; manifests mark both env vars optional so the deployment rolls without traces if the secret is absent. ch15 observability doc now lists what produces spans today vs the remaining migration work, with the explicit per-method pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
6.7 KiB
Go
204 lines
6.7 KiB
Go
package repositories
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
// WithContext returns a copy of the repository whose underlying *gorm.DB carries
|
|
// the supplied context. SQL emitted via this copy gets attached to ctx's trace span
|
|
// (when otelgorm is registered) and respects ctx cancellation/deadlines.
|
|
func (r *ContractorRepository) WithContext(ctx context.Context) *ContractorRepository {
|
|
return &ContractorRepository{db: r.db.WithContext(ctx)}
|
|
}
|