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>
783 lines
24 KiB
Go
783 lines
24 KiB
Go
package repositories
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
)
|
|
|
|
var (
|
|
ErrUserNotFound = errors.New("user not found")
|
|
ErrUserExists = errors.New("user already exists")
|
|
ErrInvalidToken = errors.New("invalid token")
|
|
ErrTokenNotFound = errors.New("token not found")
|
|
ErrCodeNotFound = errors.New("code not found")
|
|
ErrCodeExpired = errors.New("code expired")
|
|
ErrCodeUsed = errors.New("code already used")
|
|
ErrTooManyAttempts = errors.New("too many attempts")
|
|
ErrRateLimitExceeded = errors.New("rate limit exceeded")
|
|
ErrAppleAuthNotFound = errors.New("apple social auth not found")
|
|
ErrGoogleAuthNotFound = errors.New("google social auth not found")
|
|
)
|
|
|
|
// UserRepository handles user-related database operations
|
|
type UserRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewUserRepository creates a new user repository
|
|
func NewUserRepository(db *gorm.DB) *UserRepository {
|
|
return &UserRepository{db: db}
|
|
}
|
|
|
|
// DB returns the underlying *gorm.DB connection. This is useful when callers
|
|
// need to pass the connection (e.g., a transaction) to methods that accept *gorm.DB.
|
|
func (r *UserRepository) DB() *gorm.DB {
|
|
return r.db
|
|
}
|
|
|
|
// Transaction runs fn inside a database transaction. The callback receives a
|
|
// new UserRepository backed by the transaction so all operations within fn
|
|
// share the same transactional connection.
|
|
func (r *UserRepository) Transaction(fn func(txRepo *UserRepository) error) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
txRepo := &UserRepository{db: tx}
|
|
return fn(txRepo)
|
|
})
|
|
}
|
|
|
|
// FindByID finds a user by ID
|
|
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
|
var user models.User
|
|
if err := r.db.First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// FindByIDWithProfile finds a user by ID with profile preloaded
|
|
func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) {
|
|
var user models.User
|
|
if err := r.db.Preload("Profile").First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// FindByUsername finds a user by username (case-insensitive)
|
|
func (r *UserRepository) FindByUsername(username string) (*models.User, error) {
|
|
var user models.User
|
|
if err := r.db.Where("LOWER(username) = LOWER(?)", username).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// FindByEmail finds a user by email (case-insensitive)
|
|
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
|
|
var user models.User
|
|
if err := r.db.Where("LOWER(email) = LOWER(?)", email).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// FindByUsernameOrEmail finds a user by username or email with profile preloaded
|
|
func (r *UserRepository) FindByUsernameOrEmail(identifier string) (*models.User, error) {
|
|
var user models.User
|
|
if err := r.db.Preload("Profile").Where("LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", identifier, identifier).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// Create creates a new user
|
|
func (r *UserRepository) Create(user *models.User) error {
|
|
return r.db.Create(user).Error
|
|
}
|
|
|
|
// Update updates a user
|
|
func (r *UserRepository) Update(user *models.User) error {
|
|
return r.db.Save(user).Error
|
|
}
|
|
|
|
// UpdateLastLogin updates the user's last login timestamp
|
|
func (r *UserRepository) UpdateLastLogin(userID uint) error {
|
|
now := time.Now().UTC()
|
|
return r.db.Model(&models.User{}).Where("id = ?", userID).Update("last_login", now).Error
|
|
}
|
|
|
|
// ExistsByUsername checks if a username exists
|
|
func (r *UserRepository) ExistsByUsername(username string) (bool, error) {
|
|
var count int64
|
|
if err := r.db.Model(&models.User{}).Where("LOWER(username) = LOWER(?)", username).Count(&count).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// ExistsByEmail checks if an email exists
|
|
func (r *UserRepository) ExistsByEmail(email string) (bool, error) {
|
|
var count int64
|
|
if err := r.db.Model(&models.User{}).Where("LOWER(email) = LOWER(?)", email).Count(&count).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// --- Auth Token Methods ---
|
|
|
|
// GetOrCreateToken gets or creates an auth token for a user.
|
|
// Wrapped in a transaction to prevent race conditions where two
|
|
// concurrent requests could create duplicate tokens for the same user.
|
|
func (r *UserRepository) GetOrCreateToken(userID uint) (*models.AuthToken, error) {
|
|
var token models.AuthToken
|
|
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
result := tx.Where("user_id = ?", userID).First(&token)
|
|
|
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
token = models.AuthToken{UserID: userID}
|
|
if err := tx.Create(&token).Error; err != nil {
|
|
return err
|
|
}
|
|
} else if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &token, nil
|
|
}
|
|
|
|
// FindTokenByKey looks up an auth token by its key value.
|
|
func (r *UserRepository) FindTokenByKey(key string) (*models.AuthToken, error) {
|
|
var token models.AuthToken
|
|
if err := r.db.Where("key = ?", key).First(&token).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTokenNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
// CreateToken creates a new auth token for a user.
|
|
func (r *UserRepository) CreateToken(userID uint) (*models.AuthToken, error) {
|
|
token := models.AuthToken{UserID: userID}
|
|
if err := r.db.Create(&token).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
// DeleteToken deletes an auth token
|
|
func (r *UserRepository) DeleteToken(token string) error {
|
|
result := r.db.Where("key = ?", token).Delete(&models.AuthToken{})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrTokenNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTokenByUserID deletes an auth token by user ID
|
|
func (r *UserRepository) DeleteTokenByUserID(userID uint) error {
|
|
return r.db.Where("user_id = ?", userID).Delete(&models.AuthToken{}).Error
|
|
}
|
|
|
|
// --- User Profile Methods ---
|
|
|
|
// GetOrCreateProfile gets or creates a user profile
|
|
func (r *UserRepository) GetOrCreateProfile(userID uint) (*models.UserProfile, error) {
|
|
var profile models.UserProfile
|
|
result := r.db.Where("user_id = ?", userID).First(&profile)
|
|
|
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
profile = models.UserProfile{UserID: userID}
|
|
if err := r.db.Create(&profile).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
} else if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
|
|
return &profile, nil
|
|
}
|
|
|
|
// UpdateProfile updates a user profile
|
|
func (r *UserRepository) UpdateProfile(profile *models.UserProfile) error {
|
|
return r.db.Save(profile).Error
|
|
}
|
|
|
|
// SetProfileVerified sets the profile verified status
|
|
func (r *UserRepository) SetProfileVerified(userID uint, verified bool) error {
|
|
return r.db.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("verified", verified).Error
|
|
}
|
|
|
|
// --- Confirmation Code Methods ---
|
|
|
|
// CreateConfirmationCode creates a new confirmation code
|
|
func (r *UserRepository) CreateConfirmationCode(userID uint, code string, expiresAt time.Time) (*models.ConfirmationCode, error) {
|
|
// Invalidate any existing unused codes for this user
|
|
r.db.Model(&models.ConfirmationCode{}).
|
|
Where("user_id = ? AND is_used = ?", userID, false).
|
|
Update("is_used", true)
|
|
|
|
confirmCode := &models.ConfirmationCode{
|
|
UserID: userID,
|
|
Code: code,
|
|
ExpiresAt: expiresAt,
|
|
IsUsed: false,
|
|
}
|
|
|
|
if err := r.db.Create(confirmCode).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return confirmCode, nil
|
|
}
|
|
|
|
// FindConfirmationCode finds a valid confirmation code for a user
|
|
func (r *UserRepository) FindConfirmationCode(userID uint, code string) (*models.ConfirmationCode, error) {
|
|
var confirmCode models.ConfirmationCode
|
|
if err := r.db.Where("user_id = ? AND code = ? AND is_used = ?", userID, code, false).
|
|
First(&confirmCode).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrCodeNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if !confirmCode.IsValid() {
|
|
if confirmCode.IsUsed {
|
|
return nil, ErrCodeUsed
|
|
}
|
|
return nil, ErrCodeExpired
|
|
}
|
|
|
|
return &confirmCode, nil
|
|
}
|
|
|
|
// MarkConfirmationCodeUsed marks a confirmation code as used
|
|
func (r *UserRepository) MarkConfirmationCodeUsed(codeID uint) error {
|
|
return r.db.Model(&models.ConfirmationCode{}).Where("id = ?", codeID).Update("is_used", true).Error
|
|
}
|
|
|
|
// --- Password Reset Code Methods ---
|
|
|
|
// CreatePasswordResetCode creates a new password reset code
|
|
func (r *UserRepository) CreatePasswordResetCode(userID uint, codeHash string, resetToken string, expiresAt time.Time) (*models.PasswordResetCode, error) {
|
|
// Invalidate any existing unused codes for this user
|
|
r.db.Model(&models.PasswordResetCode{}).
|
|
Where("user_id = ? AND used = ?", userID, false).
|
|
Update("used", true)
|
|
|
|
resetCode := &models.PasswordResetCode{
|
|
UserID: userID,
|
|
CodeHash: codeHash,
|
|
ResetToken: resetToken,
|
|
ExpiresAt: expiresAt,
|
|
Used: false,
|
|
Attempts: 0,
|
|
MaxAttempts: 5,
|
|
}
|
|
|
|
if err := r.db.Create(resetCode).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return resetCode, nil
|
|
}
|
|
|
|
// FindPasswordResetCode finds a password reset code by email and checks validity
|
|
func (r *UserRepository) FindPasswordResetCodeByEmail(email string) (*models.PasswordResetCode, *models.User, error) {
|
|
user, err := r.FindByEmail(email)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var resetCode models.PasswordResetCode
|
|
if err := r.db.Where("user_id = ? AND used = ?", user.ID, false).
|
|
Order("created_at DESC").
|
|
First(&resetCode).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil, ErrCodeNotFound
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
return &resetCode, user, nil
|
|
}
|
|
|
|
// FindPasswordResetCodeByToken finds a password reset code by reset token
|
|
func (r *UserRepository) FindPasswordResetCodeByToken(resetToken string) (*models.PasswordResetCode, error) {
|
|
var resetCode models.PasswordResetCode
|
|
if err := r.db.Where("reset_token = ?", resetToken).First(&resetCode).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrCodeNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if !resetCode.IsValid() {
|
|
if resetCode.Used {
|
|
return nil, ErrCodeUsed
|
|
}
|
|
if resetCode.Attempts >= resetCode.MaxAttempts {
|
|
return nil, ErrTooManyAttempts
|
|
}
|
|
return nil, ErrCodeExpired
|
|
}
|
|
|
|
return &resetCode, nil
|
|
}
|
|
|
|
// IncrementResetCodeAttempts increments the attempt counter
|
|
func (r *UserRepository) IncrementResetCodeAttempts(codeID uint) error {
|
|
return r.db.Model(&models.PasswordResetCode{}).Where("id = ?", codeID).
|
|
Update("attempts", gorm.Expr("attempts + 1")).Error
|
|
}
|
|
|
|
// MarkPasswordResetCodeUsed marks a password reset code as used
|
|
func (r *UserRepository) MarkPasswordResetCodeUsed(codeID uint) error {
|
|
return r.db.Model(&models.PasswordResetCode{}).Where("id = ?", codeID).Update("used", true).Error
|
|
}
|
|
|
|
// CountRecentPasswordResetRequests counts reset requests in the last hour
|
|
func (r *UserRepository) CountRecentPasswordResetRequests(userID uint) (int64, error) {
|
|
var count int64
|
|
oneHourAgo := time.Now().UTC().Add(-1 * time.Hour)
|
|
if err := r.db.Model(&models.PasswordResetCode{}).
|
|
Where("user_id = ? AND created_at > ?", userID, oneHourAgo).
|
|
Count(&count).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// --- Search Methods ---
|
|
|
|
// SearchUsers searches users by username, email, first name, or last name
|
|
func (r *UserRepository) SearchUsers(query string, limit, offset int) ([]models.User, int64, error) {
|
|
var users []models.User
|
|
var total int64
|
|
|
|
searchQuery := "%" + escapeLikeWildcards(strings.ToLower(query)) + "%"
|
|
|
|
baseQuery := r.db.Model(&models.User{}).
|
|
Where("LOWER(username) LIKE ? OR LOWER(email) LIKE ? OR LOWER(first_name) LIKE ? OR LOWER(last_name) LIKE ?",
|
|
searchQuery, searchQuery, searchQuery, searchQuery)
|
|
|
|
if err := baseQuery.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
if err := baseQuery.Offset(offset).Limit(limit).Find(&users).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return users, total, nil
|
|
}
|
|
|
|
// ListUsers lists all users with pagination
|
|
func (r *UserRepository) ListUsers(limit, offset int) ([]models.User, int64, error) {
|
|
var users []models.User
|
|
var total int64
|
|
|
|
if err := r.db.Model(&models.User{}).Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
if err := r.db.Offset(offset).Limit(limit).Find(&users).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return users, total, nil
|
|
}
|
|
|
|
// FindUsersInSharedResidences finds users that share at least one residence with the given user
|
|
func (r *UserRepository) FindUsersInSharedResidences(userID uint) ([]models.User, error) {
|
|
var users []models.User
|
|
|
|
// Find all users that share a residence with the given user
|
|
// This includes:
|
|
// 1. Owners of residences where current user is a member
|
|
// 2. Members of residences owned by current user
|
|
// 3. Members of residences where current user is also a member
|
|
err := r.db.Raw(`
|
|
SELECT DISTINCT u.* FROM auth_user u
|
|
WHERE u.id != ? AND u.is_active = true AND (
|
|
-- Users who own residences where current user is a shared user
|
|
u.id IN (
|
|
SELECT r.owner_id FROM residence_residence r
|
|
INNER JOIN residence_residence_users ru ON r.id = ru.residence_id
|
|
WHERE ru.user_id = ? AND r.is_active = true
|
|
)
|
|
OR
|
|
-- Users who are shared users of residences owned by current user
|
|
u.id IN (
|
|
SELECT ru.user_id FROM residence_residence_users ru
|
|
INNER JOIN residence_residence r ON ru.residence_id = r.id
|
|
WHERE r.owner_id = ? AND r.is_active = true
|
|
)
|
|
OR
|
|
-- Users who share a residence with current user (both are shared users)
|
|
u.id IN (
|
|
SELECT ru2.user_id FROM residence_residence_users ru1
|
|
INNER JOIN residence_residence_users ru2 ON ru1.residence_id = ru2.residence_id
|
|
WHERE ru1.user_id = ? AND ru2.user_id != ?
|
|
)
|
|
)
|
|
`, userID, userID, userID, userID, userID).Scan(&users).Error
|
|
|
|
return users, err
|
|
}
|
|
|
|
// FindUserIfSharedResidence finds a user if they share a residence with the requesting user
|
|
func (r *UserRepository) FindUserIfSharedResidence(targetUserID, requestingUserID uint) (*models.User, error) {
|
|
var user models.User
|
|
|
|
err := r.db.Raw(`
|
|
SELECT u.* FROM auth_user u
|
|
WHERE u.id = ? AND u.is_active = true AND (
|
|
u.id = ? OR
|
|
-- Target owns a residence where requester is a member
|
|
u.id IN (
|
|
SELECT r.owner_id FROM residence_residence r
|
|
INNER JOIN residence_residence_users ru ON r.id = ru.residence_id
|
|
WHERE ru.user_id = ? AND r.is_active = true
|
|
)
|
|
OR
|
|
-- Target is a member of a residence owned by requester
|
|
u.id IN (
|
|
SELECT ru.user_id FROM residence_residence_users ru
|
|
INNER JOIN residence_residence r ON ru.residence_id = r.id
|
|
WHERE r.owner_id = ? AND r.is_active = true
|
|
)
|
|
OR
|
|
-- Target shares a residence with requester (both are shared users)
|
|
u.id IN (
|
|
SELECT ru2.user_id FROM residence_residence_users ru1
|
|
INNER JOIN residence_residence_users ru2 ON ru1.residence_id = ru2.residence_id
|
|
WHERE ru1.user_id = ?
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`, targetUserID, requestingUserID, requestingUserID, requestingUserID, requestingUserID).Scan(&user).Error
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user.ID == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
// FindProfilesInSharedResidences finds user profiles for users in shared residences
|
|
func (r *UserRepository) FindProfilesInSharedResidences(userID uint) ([]models.UserProfile, error) {
|
|
var profiles []models.UserProfile
|
|
|
|
err := r.db.Raw(`
|
|
SELECT p.* FROM user_userprofile p
|
|
INNER JOIN auth_user u ON p.user_id = u.id
|
|
WHERE u.is_active = true AND (
|
|
u.id = ? OR
|
|
-- Users who own residences where current user is a shared user
|
|
u.id IN (
|
|
SELECT r.owner_id FROM residence_residence r
|
|
INNER JOIN residence_residence_users ru ON r.id = ru.residence_id
|
|
WHERE ru.user_id = ? AND r.is_active = true
|
|
)
|
|
OR
|
|
-- Users who are shared users of residences owned by current user
|
|
u.id IN (
|
|
SELECT ru.user_id FROM residence_residence_users ru
|
|
INNER JOIN residence_residence r ON ru.residence_id = r.id
|
|
WHERE r.owner_id = ? AND r.is_active = true
|
|
)
|
|
OR
|
|
-- Users who share a residence with current user (both are shared users)
|
|
u.id IN (
|
|
SELECT ru2.user_id FROM residence_residence_users ru1
|
|
INNER JOIN residence_residence_users ru2 ON ru1.residence_id = ru2.residence_id
|
|
WHERE ru1.user_id = ?
|
|
)
|
|
)
|
|
`, userID, userID, userID, userID).Scan(&profiles).Error
|
|
|
|
return profiles, err
|
|
}
|
|
|
|
// --- Auth Provider Detection ---
|
|
|
|
// FindAuthProvider determines the auth provider for a user.
|
|
// Returns "apple", "google", or "email".
|
|
func (r *UserRepository) FindAuthProvider(userID uint) (string, error) {
|
|
var count int64
|
|
if err := r.db.Model(&models.AppleSocialAuth{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
|
return "", err
|
|
}
|
|
if count > 0 {
|
|
return "apple", nil
|
|
}
|
|
|
|
if err := r.db.Model(&models.GoogleSocialAuth{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
|
return "", err
|
|
}
|
|
if count > 0 {
|
|
return "google", nil
|
|
}
|
|
|
|
return "email", nil
|
|
}
|
|
|
|
// --- Account Deletion ---
|
|
|
|
// DeleteUserCascade deletes a user and all related records in dependency order.
|
|
// Should be called on a repository backed by a transaction (via Transaction callback).
|
|
// Returns a list of file URLs that need to be deleted from disk after the transaction commits.
|
|
func (r *UserRepository) DeleteUserCascade(userID uint) ([]string, error) {
|
|
var fileURLs []string
|
|
db := r.db
|
|
|
|
// 1. Push notification devices
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.APNSDevice{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.GCMDevice{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 2. Notifications
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.Notification{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 3. Notification preferences
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.NotificationPreference{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 4. Task reminder logs
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.TaskReminderLog{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 5. Find residences owned by user
|
|
var ownedResidences []models.Residence
|
|
if err := db.Where("owner_id = ?", userID).Find(&ownedResidences).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, residence := range ownedResidences {
|
|
// Collect file URLs before deleting
|
|
|
|
// Task completion images (via completion_id -> task_id -> residence_id)
|
|
var completionImageURLs []string
|
|
db.Model(&models.TaskCompletionImage{}).
|
|
Joins("JOIN task_taskcompletion ON task_taskcompletion.id = task_taskcompletionimage.completion_id").
|
|
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
|
Where("task_task.residence_id = ?", residence.ID).
|
|
Pluck("task_taskcompletionimage.image_url", &completionImageURLs)
|
|
fileURLs = append(fileURLs, completionImageURLs...)
|
|
|
|
// Delete task completion images
|
|
db.Exec(`DELETE FROM task_taskcompletionimage WHERE completion_id IN (
|
|
SELECT tc.id FROM task_taskcompletion tc
|
|
JOIN task_task t ON t.id = tc.task_id
|
|
WHERE t.residence_id = ?
|
|
)`, residence.ID)
|
|
|
|
// Delete task completions
|
|
db.Exec(`DELETE FROM task_taskcompletion WHERE task_id IN (
|
|
SELECT id FROM task_task WHERE residence_id = ?
|
|
)`, residence.ID)
|
|
|
|
// Document images (via document_id -> residence_id)
|
|
var docImageURLs []string
|
|
db.Model(&models.DocumentImage{}).
|
|
Joins("JOIN task_document ON task_document.id = task_documentimage.document_id").
|
|
Where("task_document.residence_id = ?", residence.ID).
|
|
Pluck("task_documentimage.image_url", &docImageURLs)
|
|
fileURLs = append(fileURLs, docImageURLs...)
|
|
|
|
// Delete document images
|
|
db.Exec(`DELETE FROM task_documentimage WHERE document_id IN (
|
|
SELECT id FROM task_document WHERE residence_id = ?
|
|
)`, residence.ID)
|
|
|
|
// Document file URLs
|
|
var docFileURLs []string
|
|
db.Model(&models.Document{}).Where("residence_id = ?", residence.ID).Pluck("file_url", &docFileURLs)
|
|
fileURLs = append(fileURLs, docFileURLs...)
|
|
|
|
// Delete documents
|
|
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.Document{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Delete tasks
|
|
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.Task{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Delete contractor specialties (many-to-many join table)
|
|
db.Exec(`DELETE FROM task_contractor_specialties WHERE contractor_id IN (
|
|
SELECT id FROM task_contractor WHERE residence_id = ?
|
|
)`, residence.ID)
|
|
|
|
// Delete contractors
|
|
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.Contractor{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Delete share codes
|
|
if err := db.Where("residence_id = ?", residence.ID).Delete(&models.ResidenceShareCode{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Remove residence membership records (many-to-many join table)
|
|
db.Exec("DELETE FROM residence_residence_users WHERE residence_id = ?", residence.ID)
|
|
|
|
// Delete the residence itself
|
|
if err := db.Delete(&residence).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// 6. Remove user from shared residences they don't own (membership only)
|
|
db.Exec("DELETE FROM residence_residence_users WHERE user_id = ?", userID)
|
|
|
|
// 7. Subscription
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.UserSubscription{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 8. Social auth records
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.AppleSocialAuth{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.GoogleSocialAuth{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 9. Confirmation codes
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.ConfirmationCode{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 10. Password reset codes
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.PasswordResetCode{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 11. Auth tokens
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.AuthToken{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 12. User profile
|
|
if err := db.Where("user_id = ?", userID).Delete(&models.UserProfile{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 13. User
|
|
if err := db.Where("id = ?", userID).Delete(&models.User{}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter out empty URLs
|
|
var cleanURLs []string
|
|
for _, url := range fileURLs {
|
|
if url != "" {
|
|
cleanURLs = append(cleanURLs, url)
|
|
}
|
|
}
|
|
|
|
return cleanURLs, nil
|
|
}
|
|
|
|
// --- Apple Social Auth Methods ---
|
|
|
|
// FindByAppleID finds an Apple social auth by Apple ID
|
|
func (r *UserRepository) FindByAppleID(appleID string) (*models.AppleSocialAuth, error) {
|
|
var auth models.AppleSocialAuth
|
|
if err := r.db.Where("apple_id = ?", appleID).First(&auth).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrAppleAuthNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &auth, nil
|
|
}
|
|
|
|
// CreateAppleSocialAuth creates a new Apple social auth record
|
|
func (r *UserRepository) CreateAppleSocialAuth(auth *models.AppleSocialAuth) error {
|
|
return r.db.Create(auth).Error
|
|
}
|
|
|
|
// UpdateAppleSocialAuth updates an Apple social auth record
|
|
func (r *UserRepository) UpdateAppleSocialAuth(auth *models.AppleSocialAuth) error {
|
|
return r.db.Save(auth).Error
|
|
}
|
|
|
|
// --- Google Social Auth Methods ---
|
|
|
|
// FindByGoogleID finds a Google social auth by Google ID
|
|
func (r *UserRepository) FindByGoogleID(googleID string) (*models.GoogleSocialAuth, error) {
|
|
var auth models.GoogleSocialAuth
|
|
if err := r.db.Where("google_id = ?", googleID).First(&auth).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrGoogleAuthNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &auth, nil
|
|
}
|
|
|
|
// CreateGoogleSocialAuth creates a new Google social auth record
|
|
func (r *UserRepository) CreateGoogleSocialAuth(auth *models.GoogleSocialAuth) error {
|
|
return r.db.Create(auth).Error
|
|
}
|
|
|
|
// UpdateGoogleSocialAuth updates a Google social auth record
|
|
func (r *UserRepository) UpdateGoogleSocialAuth(auth *models.GoogleSocialAuth) error {
|
|
return r.db.Save(auth).Error
|
|
}
|
|
|
|
// 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 *UserRepository) WithContext(ctx context.Context) *UserRepository {
|
|
return &UserRepository{db: r.db.WithContext(ctx)}
|
|
}
|