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/internal/square" "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) } // Snapshot the Square card IDs AND customer IDs synchronously BEFORE the // SQL anonymization below NULLs square_card_id/square_customer_id, so the // background cleanup still has the external Square references it needs // (previously the goroutine read the rows itself, racing the anonymize // step which wiped them mid-flight). Distinct non-null customer IDs only: // a user's saved cards share one provisioned Square customer, so // DeleteCustomer runs once per customer. NULL customer IDs (users with // no saved cards) are skipped. var cardIDs []string customerSeen := map[string]bool{} var customerIDs []string // Capture the client synchronously so the async cleanup goroutine never // reads the global payments.SquareClient (which tests swap per-account). sqClient := payments.SquareClient if sqClient != nil { rows, err := db.Conn.Query(r.Context(), `SELECT square_card_id, square_customer_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) } else { for rows.Next() { var cardID, customerID sql.NullString if err := rows.Scan(&cardID, &customerID); err != nil { log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err) continue } if cardID.Valid && cardID.String != "" { cardIDs = append(cardIDs, cardID.String) } if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] { customerSeen[customerID.String] = true customerIDs = append(customerIDs, customerID.String) } } if err := rows.Err(); err != nil { log.Printf("Warning: Row iteration error for user %s: %v", userID, err) } rows.Close() } } // --- 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) } }() // anonymize_user() is the single source of truth for erasure: it NULLs // the 2FA columns and staff notes in the same statement that anonymizes // the rest of the row, so every call site (this handler AND the // idle-account batch cleanup in scheduling.CleanupIdleAccounts) is // GDPR-clean without a separate Go-side scrub. _, 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 } // The local erasure committed: drop the user's cached Square customer id so // the erased identity cannot resurface from the process-local cache on a // later save-card flow (the Square customer is deleted below and the DB // columns are NULLed, but the cache is never touched by either). payments.InvalidateSquareCustomerCache(userID) // external Square cleanup fires only after local anonymization/deletion // commits, so a failed local tx leaves external state intact for retry. if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) { // #nosec G118 — intentional background goroutine for async account deletion go func(client square.SquareClient, cards, customers []string) { defer func() { if r := recover(); r != nil { log.Printf("Panic recovered in Square cleanup: %v", r) } }() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() for _, cardID := range cards { if err := client.DeleteCardOnFile(ctx, cardID); err != nil { // TokenPrefix redacts the ccof: card token — the full ID must // never reach logs. log.Printf("Warning: Failed to delete Square card %s for user %s: %v", square.TokenPrefix(cardID), userID, err) } } for _, customerID := range customers { // Square customers are provisioned per-user from a deterministic // email-derived key, but the UNIQUE(email) index excludes guest // accounts — so a guest and a registered user sharing an email can // end up on the SAME Square customer profile (Square dedups within // its idempotency window). Deleting it would break the other // account's saved-card charges, so skip the deletion when any OTHER // non-deleted saved card still references the customer. var stillReferenced bool if err := db.Conn.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL) `, customerID, userID).Scan(&stillReferenced); err != nil { log.Printf("Warning: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err) continue } if stillReferenced { // PII-redacted customer id — the full id never reaches logs. log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID)) continue } if err := client.DeleteCustomer(ctx, customerID); err != nil { log.Printf("Warning: Failed to delete Square customer %s for user %s: %v", square.TokenPrefix(customerID), userID, err) } } }(sqClient, cardIDs, customerIDs) } // 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) }