package user import ( "context" "database/sql" "errors" "fmt" "log" "log/slog" "net/http" "os" "time" "crussell/db" "crussell/handlers/payments" "crussell/internal/dav" "crussell/internal/s3" "crussell/mw" "github.com/jackc/pgx/v5" ) // DELETE /api/user/account func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var accountRole string var profilePicURL sql.NullString err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID). Scan(&accountRole, &profilePicURL) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "user not found", http.StatusNotFound) return } 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 { // #nosec G118 — intentional background goroutine for async profile pic cleanup go func(picURL string) { defer func() { if r := recover(); r != nil { log.Printf("Panic recovered in S3 profile picture deletion: %v", r) } }() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() 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(ctx, bucket, key); err != nil { log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err) } }(profilePicURL.String) } if payments.SquareClient != nil { // #nosec G118 — intentional background goroutine for async account deletion go func() { defer func() { if r := recover(); r != nil { log.Printf("Panic recovered in Square saved card cleanup: %v", r) } }() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() rows, err := db.Conn.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(ctx, cardID); err != nil { log.Printf("Warning: Failed to delete Square card %s for user %s: %v", cardID, userID, err) } } if err := rows.Err(); err != nil { log.Printf("Warning: Row iteration error for user %s: %v", userID, err) return } }() } // --- SQL-level anonymization/deletion --- if accountRole == "guest" { tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction for guest user deletion: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() _, err = tx.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 } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction for guest user deletion: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } } else { tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction for user anonymization: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() _, err = tx.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) return } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction for user anonymization: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } // TODO: Create 'user_anonymized' notification for admin audit trail } // Delete CardDAV contact (non-blocking, best-effort) if dav.Service != nil { go func() { defer func() { if r := recover(); r != nil { log.Printf("Panic recovered in CardDAV contact deletion: %v", r) } }() uri := fmt.Sprintf("%s.vcf", userID) if err := dav.Service.DeleteContact(1, uri); err != nil { log.Printf("Warning: Failed to delete CardDAV contact for user %s: %v", userID, err) } }() } w.WriteHeader(http.StatusNoContent) }