Delete Account (Plan #2): - DELETE /api/auth/account/ with password or "DELETE" confirmation - Cascade delete across 15+ tables in correct FK order - Auth provider detection (email/apple/google) for /auth/me/ - File cleanup after account deletion - Handler + repository tests (12 tests) Encryption at Rest (Plan #3): - AES-256-GCM envelope encryption (per-file DEK wrapped by KEK) - Encrypt on upload, auto-decrypt on serve via StorageService.ReadFile() - MediaHandler serves decrypted files via c.Blob() - TaskService email image loading uses ReadFile() - cmd/migrate-encrypt CLI tool with --dry-run for existing files - Encryption service + storage service tests (18 tests)
754 lines
24 KiB
Go
754 lines
24 KiB
Go
package repositories
|
|
|
|
import (
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
}
|