2026-02-25 18:51:21 +00:00
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"
)
2026-03-10 12:57:04 +00:00
// DeleteAccountRequest is the request body for account deletion (v0.10.8 F065)
2026-02-25 18:51:21 +00:00
type DeleteAccountRequest struct {
2026-03-10 12:57:04 +00:00
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)
2026-02-25 18:51:21 +00:00
}
// DeleteAccountHandler returns a handler for DELETE /users/me (v0.803 SEC2-05)
2026-02-25 18:51:54 +00:00
//
// @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]
2026-02-25 18:51:21 +00:00
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"
2026-03-10 12:57:04 +00:00
// 2. Soft delete + full PII anonymization (v0.10.8 droit à l'oubli)
2026-02-25 18:51:21 +00:00
result := db . WithContext ( ctx ) . Exec ( `
UPDATE users SET
is_active = false ,
deleted_at = NOW ( ) ,
deletion_reason = ? ,
recovery_deadline = ? ,
username = ? ,
email = ? ,
2026-03-10 12:57:04 +00:00
first_name = NULL ,
last_name = NULL ,
bio = ' ' ,
location = ' ' ,
avatar = ' ' ,
banner_url = ' ' ,
2026-02-25 18:51:21 +00:00
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
}
2026-03-10 12:57:04 +00:00
// 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 )
}
2026-03-13 23:44:46 +00:00
// 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 )
2026-02-25 18:51:21 +00:00
// 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 ) )
}
}
}