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>
371 lines
11 KiB
Go
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)}
|
|
}
|