From 40840c04580f343373eb3922c00637f549d43a33 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 5 Jun 2026 12:12:12 +0100 Subject: [PATCH] feat(user): extend DeleteAccountHandler with external system scrubbing DeleteAccountHandler now scrubs external systems BEFORE SQL-level anonymization. S3: deletes profile picture from S3/R2 (profiles/{userID}.jpg) when s3.Client is configured. Square: iterates user's saved cards and calls DeleteCardOnFile for each non-deleted card when payments.SquareClient is configured. Both run in background goroutines (non-blocking, best-effort with warning logs). CardDAV contact deletion remains as existing best-effort goroutine. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/user/account.go | 57 +++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/backend/handlers/user/account.go b/backend/handlers/user/account.go index 7decf3d..c1b1764 100644 --- a/backend/handlers/user/account.go +++ b/backend/handlers/user/account.go @@ -1,13 +1,17 @@ package user import ( + "context" "database/sql" "fmt" "log" "net/http" + "os" "crussell/db" + "crussell/handlers/payments" "crussell/internal/dav" + "crussell/internal/s3" "crussell/mw" ) @@ -20,26 +24,71 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { } var accountRole string - err := db.DB.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, userID).Scan(&accountRole) + var profilePicURL sql.NullString + err := db.DB.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID). + Scan(&accountRole, &profilePicURL) if err != nil { if err == sql.ErrNoRows { http.Error(w, "user not found", http.StatusNotFound) return } - log.Printf("Failed to fetch user role for deletion: %v", err) + log.Printf("Failed to fetch user for deletion: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } + ctx := r.Context() + + // --- External system scrubbing (BEFORE SQL anonymize) --- + + // Delete profile picture from S3/R2 + if profilePicURL.Valid && profilePicURL.String != "" && s3.Client != nil { + go func(picURL string) { + bucket := os.Getenv("S3_PROFILE_PICS_BUCKET") + if bucket == "" { + bucket = "crussell-profile-pics" + } + // profiles/{userID}.jpg — matches UploadProfilePictureHandler key format + key := fmt.Sprintf("profiles/%s.jpg", userID) + if err := s3.Client.Delete(context.Background(), bucket, key); err != nil { + log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err) + } + }(profilePicURL.String) + } + + if payments.SquareClient != nil { + go func() { + rows, err := db.DB.Query(ctx, + `SELECT square_card_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID) + if err != nil { + log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err) + return + } + defer rows.Close() + for rows.Next() { + var cardID string + if err := rows.Scan(&cardID); err != nil { + log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err) + continue + } + if err := payments.SquareClient.DeleteCardOnFile(context.Background(), cardID); err != nil { + log.Printf("Warning: Failed to delete Square card %s for user %s: %v", cardID, userID, err) + } + } + }() + } + + // --- SQL-level anonymization/deletion --- + if accountRole == "guest" { - _, err = db.DB.Exec(r.Context(), `SELECT delete_guest_user($1)`, userID) + _, err = db.DB.Exec(ctx, `SELECT delete_guest_user($1)`, userID) if err != nil { log.Printf("Failed to delete guest user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError) return } } else { - _, err = db.DB.Exec(r.Context(), `SELECT anonymize_user($1)`, userID) + _, err = db.DB.Exec(ctx, `SELECT anonymize_user($1)`, userID) if err != nil { log.Printf("Failed to anonymize user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError)