Files
honeyDueAPI/internal/repositories/user_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

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)}
}