Files
honeyDueAPI/internal/repositories/residence_repo.go
T
Trey t bc3da007db
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
Wire OpenTelemetry tracing — HTTP, B2, APNs, FCM, asynq, GORM (partial)
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>
2026-04-25 15:28:05 -05:00

371 lines
11 KiB
Go

package repositories
import (
"context"
"crypto/rand"
"errors"
"math/big"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/models"
)
// ResidenceRepository handles database operations for residences
type ResidenceRepository struct {
db *gorm.DB
}
// NewResidenceRepository creates a new residence repository
func NewResidenceRepository(db *gorm.DB) *ResidenceRepository {
return &ResidenceRepository{db: db}
}
// FindByID finds a residence by ID with preloaded relations
func (r *ResidenceRepository) FindByID(id uint) (*models.Residence, error) {
var residence models.Residence
err := r.db.Preload("Owner").
Preload("Users").
Preload("PropertyType").
Where("id = ? AND is_active = ?", id, true).
First(&residence).Error
if err != nil {
return nil, err
}
return &residence, nil
}
// FindByIDSimple finds a residence by ID without preloading (for quick checks)
func (r *ResidenceRepository) FindByIDSimple(id uint) (*models.Residence, error) {
var residence models.Residence
err := r.db.Where("id = ? AND is_active = ?", id, true).First(&residence).Error
if err != nil {
return nil, err
}
return &residence, nil
}
// FindByUser finds all residences accessible to a user (owned or shared)
func (r *ResidenceRepository) FindByUser(userID uint) ([]models.Residence, error) {
var residences []models.Residence
// Find residences where user is owner OR user is in the shared users list
err := r.db.Preload("Owner").
Preload("Users").
Preload("PropertyType").
Where("is_active = ?", true).
Where("owner_id = ? OR id IN (?)",
userID,
r.db.Table("residence_residence_users").Select("residence_id").Where("user_id = ?", userID),
).
Order("is_primary DESC, created_at DESC").
Find(&residences).Error
if err != nil {
return nil, err
}
return residences, nil
}
// FindResidenceIDsByUser returns just the IDs of residences a user has access to.
// This is a lightweight alternative to FindByUser() when only IDs are needed.
// Avoids preloading Owner, Users, PropertyType relations.
func (r *ResidenceRepository) FindResidenceIDsByUser(userID uint) ([]uint, error) {
var ids []uint
err := r.db.Model(&models.Residence{}).
Where("is_active = ?", true).
Where("owner_id = ? OR id IN (?)",
userID,
r.db.Table("residence_residence_users").Select("residence_id").Where("user_id = ?", userID),
).
Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
// FindOwnedByUser finds all residences owned by a user
func (r *ResidenceRepository) FindOwnedByUser(userID uint) ([]models.Residence, error) {
var residences []models.Residence
err := r.db.Preload("Owner").
Preload("Users").
Preload("PropertyType").
Where("owner_id = ? AND is_active = ?", userID, true).
Order("is_primary DESC, created_at DESC").
Find(&residences).Error
if err != nil {
return nil, err
}
return residences, nil
}
// Create creates a new residence
func (r *ResidenceRepository) Create(residence *models.Residence) error {
return r.db.Create(residence).Error
}
// Update updates a residence
// Uses Omit to exclude associations that could interfere with Save
func (r *ResidenceRepository) Update(residence *models.Residence) error {
return r.db.Omit("Owner", "Users", "PropertyType").Save(residence).Error
}
// Delete soft-deletes a residence by setting is_active to false
func (r *ResidenceRepository) Delete(id uint) error {
return r.db.Model(&models.Residence{}).
Where("id = ?", id).
Update("is_active", false).Error
}
// AddUser adds a user to a residence's shared users
func (r *ResidenceRepository) AddUser(residenceID, userID uint) error {
// Using raw SQL for the many-to-many join table
return r.db.Exec(
"INSERT INTO residence_residence_users (residence_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
residenceID, userID,
).Error
}
// RemoveUser removes a user from a residence's shared users
func (r *ResidenceRepository) RemoveUser(residenceID, userID uint) error {
return r.db.Exec(
"DELETE FROM residence_residence_users WHERE residence_id = ? AND user_id = ?",
residenceID, userID,
).Error
}
// GetResidenceUsers returns all users with access to a residence (owner + shared users).
// Optimized: Uses a single UNION query instead of preloading full residence with relations.
func (r *ResidenceRepository) GetResidenceUsers(residenceID uint) ([]models.User, error) {
var users []models.User
// Single query to get both owner and shared users
err := r.db.Raw(`
SELECT DISTINCT u.* FROM auth_user u
WHERE u.id IN (
SELECT owner_id FROM residence_residence WHERE id = ? AND is_active = true
UNION
SELECT user_id FROM residence_residence_users WHERE residence_id = ?
)
`, residenceID, residenceID).Scan(&users).Error
if err != nil {
return nil, err
}
return users, nil
}
// HasAccess checks if a user has access to a residence
func (r *ResidenceRepository) HasAccess(residenceID, userID uint) (bool, error) {
var count int64
// Single query using UNION to check owner OR member access
err := r.db.Raw(`
SELECT COUNT(*) FROM (
SELECT 1 FROM residence_residence
WHERE id = ? AND owner_id = ? AND is_active = true
UNION
SELECT 1 FROM residence_residence_users
WHERE residence_id = ? AND user_id = ?
) access_check
`, residenceID, userID, residenceID, userID).Scan(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// IsOwner checks if a user is the owner of a residence
func (r *ResidenceRepository) IsOwner(residenceID, userID uint) (bool, error) {
var count int64
err := r.db.Model(&models.Residence{}).
Where("id = ? AND owner_id = ? AND is_active = ?", residenceID, userID, true).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// CountByOwner counts residences owned by a user
func (r *ResidenceRepository) CountByOwner(userID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Residence{}).
Where("owner_id = ? AND is_active = ?", userID, true).
Count(&count).Error
return count, err
}
// FindResidenceIDsByOwner returns just the IDs of residences a user owns.
// This is a lightweight alternative to FindOwnedByUser() when only IDs are needed
// for batch queries against related tables (tasks, contractors, documents).
func (r *ResidenceRepository) FindResidenceIDsByOwner(userID uint) ([]uint, error) {
var ids []uint
err := r.db.Model(&models.Residence{}).
Where("owner_id = ? AND is_active = ?", userID, true).
Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
// === Share Code Operations ===
// CreateShareCode creates a new share code for a residence
func (r *ResidenceRepository) CreateShareCode(residenceID, createdByID uint, expiresIn time.Duration) (*models.ResidenceShareCode, error) {
// Deactivate existing codes for this residence
err := r.db.Model(&models.ResidenceShareCode{}).
Where("residence_id = ? AND is_active = ?", residenceID, true).
Update("is_active", false).Error
if err != nil {
return nil, err
}
// Generate unique 6-character code
code, err := r.generateUniqueCode()
if err != nil {
return nil, err
}
expiresAt := time.Now().UTC().Add(expiresIn)
shareCode := &models.ResidenceShareCode{
ResidenceID: residenceID,
Code: code,
CreatedByID: createdByID,
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := r.db.Create(shareCode).Error; err != nil {
return nil, err
}
return shareCode, nil
}
// FindShareCodeByCode finds an active share code by its code string
func (r *ResidenceRepository) FindShareCodeByCode(code string) (*models.ResidenceShareCode, error) {
var shareCode models.ResidenceShareCode
err := r.db.Preload("Residence").
Where("code = ? AND is_active = ?", code, true).
First(&shareCode).Error
if err != nil {
return nil, err
}
// Check if expired
if shareCode.ExpiresAt != nil && time.Now().UTC().After(*shareCode.ExpiresAt) {
return nil, errors.New("share code has expired")
}
return &shareCode, nil
}
// DeactivateShareCode deactivates a share code
func (r *ResidenceRepository) DeactivateShareCode(codeID uint) error {
return r.db.Model(&models.ResidenceShareCode{}).
Where("id = ?", codeID).
Update("is_active", false).Error
}
// GetActiveShareCode gets the active share code for a residence (if any)
func (r *ResidenceRepository) GetActiveShareCode(residenceID uint) (*models.ResidenceShareCode, error) {
var shareCode models.ResidenceShareCode
err := r.db.Where("residence_id = ? AND is_active = ?", residenceID, true).
First(&shareCode).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
// Check if expired
if shareCode.ExpiresAt != nil && time.Now().UTC().After(*shareCode.ExpiresAt) {
// Auto-deactivate expired code
if err := r.DeactivateShareCode(shareCode.ID); err != nil {
log.Error().Err(err).Uint("code_id", shareCode.ID).Msg("Failed to deactivate expired share code")
}
return nil, nil
}
return &shareCode, nil
}
// generateUniqueCode generates a unique 6-character alphanumeric code
func (r *ResidenceRepository) generateUniqueCode() (string, error) {
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // Removed ambiguous chars: 0, O, I, 1
const codeLength = 6
maxAttempts := 10
for attempt := 0; attempt < maxAttempts; attempt++ {
code := make([]byte, codeLength)
for i := range code {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", err
}
code[i] = charset[num.Int64()]
}
codeStr := string(code)
// Check if code already exists
var count int64
if err := r.db.Model(&models.ResidenceShareCode{}).
Where("code = ? AND is_active = ?", codeStr, true).
Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
return codeStr, nil
}
}
return "", errors.New("failed to generate unique share code")
}
// === Residence Type Operations ===
// GetAllResidenceTypes returns all residence types
func (r *ResidenceRepository) GetAllResidenceTypes() ([]models.ResidenceType, error) {
var types []models.ResidenceType
err := r.db.Order("id").Find(&types).Error
return types, err
}
// FindResidenceTypeByID finds a residence type by ID
func (r *ResidenceRepository) FindResidenceTypeByID(id uint) (*models.ResidenceType, error) {
var residenceType models.ResidenceType
err := r.db.First(&residenceType, id).Error
if err != nil {
return nil, err
}
return &residenceType, nil
}
// GetTasksForReport returns all tasks for a residence with related data for report generation
func (r *ResidenceRepository) GetTasksForReport(residenceID uint) ([]models.Task, error) {
var tasks []models.Task
err := r.db.
Preload("Category").
Preload("Priority").
Preload("Completions").
Preload("Completions.Images").
Preload("Completions.CompletedBy").
Where("residence_id = ?", residenceID).
Order("due_date ASC NULLS LAST, created_at DESC").
Find(&tasks).Error
return tasks, err
}
// 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 *ResidenceRepository) WithContext(ctx context.Context) *ResidenceRepository {
return &ResidenceRepository{db: r.db.WithContext(ctx)}
}