package handlers import ( "context" "net/http" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/services" "veza-backend-api/internal/utils" ) // DeleteAccountRequest is the request body for account deletion (v0.10.8 F065) type DeleteAccountRequest struct { Password string `json:"password" binding:"required"` Reason string `json:"reason"` ConfirmText string `json:"confirm_text" binding:"required"` KeepPublicTracks bool `json:"keep_public_tracks"` // If true, public tracks remain (attributed to deleted account) } // DeleteAccountHandler returns a handler for DELETE /users/me (v0.803 SEC2-05) // // @Summary Delete account // @Description Permanently delete user account with anonymization, session revocation, audit log // @Tags Users // @Security BearerAuth // @Param body body DeleteAccountRequest true "Password, reason, confirm_text (must be DELETE)" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]interface{} // @Failure 401 {object} map[string]interface{} // @Failure 500 {object} map[string]interface{} // @Router /users/me [delete] func DeleteAccountHandler( db *gorm.DB, sessionService *services.SessionService, auditService *services.AuditService, s3Service *services.S3StorageService, logger *zap.Logger, ) gin.HandlerFunc { refreshTokenService := services.NewRefreshTokenService(db) return func(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok || userID == uuid.Nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } var req DeleteAccountRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: password and confirm_text required"}) return } if req.ConfirmText != "DELETE" { c.JSON(http.StatusBadRequest, gin.H{"error": "Type DELETE to confirm"}) return } // 1. Verify password var passwordHash string if err := db.WithContext(c.Request.Context()).Raw("SELECT password_hash FROM users WHERE id = ?", userID).Scan(&passwordHash).Error; err != nil || passwordHash == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"}) return } if err := utils.CheckPasswordHash(req.Password, passwordHash); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"}) return } ctx := c.Request.Context() recoveryDeadline := time.Now().Add(30 * 24 * time.Hour) anonUsername := "deleted-" + userID.String() anonEmail := "deleted-" + userID.String() + "@veza.app" // 2. Soft delete + full PII anonymization (v0.10.8 droit à l'oubli) result := db.WithContext(ctx).Exec(` UPDATE users SET is_active = false, deleted_at = NOW(), deletion_reason = ?, recovery_deadline = ?, username = ?, email = ?, first_name = NULL, last_name = NULL, bio = '', location = '', avatar = '', banner_url = '', updated_at = NOW() WHERE id = ? `, req.Reason, recoveryDeadline, anonUsername, anonEmail, userID) if result.Error != nil { logger.Error("Failed to delete account", zap.Error(result.Error), zap.String("user_id", userID.String())) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete account"}) return } // 2b. Anonymize user_profiles if exists (v0.10.8) _ = db.WithContext(ctx).Exec("UPDATE user_profiles SET bio = NULL, tagline = NULL, location = NULL, website_url = NULL, avatar_url = NULL, banner_url = NULL, updated_at = NOW() WHERE user_id = ?", userID) // 2c. Tracks: if !KeepPublicTracks, soft-delete all user tracks (v0.10.8) if !req.KeepPublicTracks { _ = db.WithContext(ctx).Exec("UPDATE tracks SET deleted_at = NOW() WHERE creator_id = ?", userID) } // 2d. SECURITY(REM-013): Anonymize financial data for GDPR compliance. // Orders are anonymized (not deleted) for legal/accounting obligations. _ = db.WithContext(ctx).Exec("UPDATE orders SET buyer_id = ? WHERE buyer_id = ?", uuid.Nil, userID) // Revoke active licenses _ = db.WithContext(ctx).Exec("UPDATE licenses SET revoked_at = NOW() WHERE buyer_id = ? AND revoked_at IS NULL", userID) // Zero out seller balances (prevent orphaned payouts) _ = db.WithContext(ctx).Exec("UPDATE seller_balances SET available_cents = 0, pending_cents = 0 WHERE seller_id = ?", userID) // Cancel pending payouts _ = db.WithContext(ctx).Exec("UPDATE seller_payouts SET status = 'cancelled' WHERE seller_id = ? AND status IN ('pending', 'processing')", userID) // Cancel active subscriptions _ = db.WithContext(ctx).Exec("UPDATE user_subscriptions SET status = 'cancelled', cancelled_at = NOW() WHERE user_id = ? AND status IN ('active', 'trialing')", userID) // 3. Revoke all sessions if sessionService != nil { if _, err := sessionService.RevokeAllUserSessions(ctx, userID); err != nil { logger.Warn("Failed to revoke sessions during account deletion", zap.Error(err), zap.String("user_id", userID.String())) } } // 4. Revoke all refresh tokens if err := refreshTokenService.RevokeAll(userID); err != nil { logger.Warn("Failed to revoke refresh tokens during account deletion", zap.Error(err), zap.String("user_id", userID.String())) } // 5. S3 cleanup (cloud files for user) if s3Service != nil { deleteUserS3Files(ctx, db, s3Service, userID, logger) } // 6. Audit log if auditService != nil { _ = auditService.LogDeletion(ctx, userID, "user", userID, c.ClientIP(), c.Request.UserAgent()) } RespondSuccess(c, http.StatusOK, gin.H{"message": "Account deleted"}) } } // deleteUserS3Files deletes cloud files for a user from S3 func deleteUserS3Files(ctx context.Context, db *gorm.DB, s3Service *services.S3StorageService, userID uuid.UUID, logger *zap.Logger) { var files []struct { S3Key string } if err := db.WithContext(ctx).Raw("SELECT s3_key FROM user_files WHERE user_id = ?", userID).Scan(&files).Error; err != nil { logger.Warn("Failed to list user files for S3 cleanup", zap.Error(err), zap.String("user_id", userID.String())) return } for _, f := range files { if err := s3Service.DeleteFile(ctx, f.S3Key); err != nil { logger.Warn("Failed to delete S3 file during account deletion", zap.Error(err), zap.String("key", f.S3Key)) } } }