Add delete account endpoint and file encryption at rest

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)
This commit is contained in:
Trey T
2026-03-26 10:41:01 -05:00
parent 72866e935e
commit 4abc57535e
22 changed files with 1675 additions and 82 deletions

View File

@@ -34,6 +34,12 @@ 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.
@@ -509,6 +515,195 @@ func (r *UserRepository) FindProfilesInSharedResidences(userID uint) ([]models.U
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