Skip to content

CampTix: "Unknown Attendee" tickets show up on the public attendees page #2114

Closed

Description

@wputasic

Describe the bug

Unclaimed tickets, where the buyer ticked "I don't know who will use this ticket yet", can show up on the public [camptix_attendees] page as Unknown Attendee. One place this is visible right now is https://asia.wordcamp.org/2027/attendees/.

CampTix already tries to keep unclaimed tickets off this page, so these rows shouldn't appear. The filter that does it checks a different field from the one the other unknown-attendee check uses, and some records slip through.

Why it happens

"Unknown Attendee" is placeholder data. add_unknown_attendee_info_stubs() writes it into the attendee's meta at checkout (require-login.php:591, values from get_unknown_attendee_info() at L727):

  • tix_first_name = Unknown, tix_last_name = Attendee
  • tix_email = unknown.attendee@example.org (UNKNOWN_ATTENDEE_EMAIL)

The Require Login addon has two checks that should exclude these records, and each one tests a different field:

Check What it tests
hide_unconfirmed_attendees() (L916): public attendees list tix_username != '[[ unconfirmed ]]' (or tix_username NOT EXISTS)
prevent_unknown_attendees_viewing_private_content() (L945): private content tix_email != UNKNOWN_ATTENDEE_EMAIL

The public-list check assumes that an unclaimed ticket always has the [[ unconfirmed ]] username. That isn't guaranteed:

  • add_username_to_attendee_object() (L337) gives row 1 (the buyer) their real user_login and gives every other row the sentinel.
  • add_unknown_attendee_info_stubs() has no row check. It adds the placeholder data to any row that has unknown_attendee set.
  • The only thing stopping row 1 from being marked unknown is that the checkbox is hidden on that row (inject_unknown_attendee_checkbox() / current_row_is_buyer(), L523/L565). The server never checks this. Since Require Login: When landing on the tickets screen, allow the buyer to mark additional tickets as "unknown attendee". #1480, the checkbox is also shown on row 1 whenever $form_data['tix_tickets_selected'] is empty. For example, a GET landing through tix_single_ticket_purchase fills in $_REQUEST but not $_POST/form_data (camptix.php:4196-4202).

So a record with the placeholder name and email plus a real username gets blocked from private content, which is correct. The public list still shows it, which is the bug.

Steps to reproduce

  1. Create a published tix_attendee post with:
    • tix_first_name = Unknown, tix_last_name = Attendee
    • tix_email = unknown.attendee@example.org
    • tix_username = any real username
  2. Render a page with [camptix_attendees] after clearing the camptix-attendees-* transients.
  3. Unknown Attendee appears in the list.

Organisers can check which case affects their site: in CampTix → Attendees → Export, the Username column for the "Unknown Attendee" rows will show a real login.

Expected behaviour

Unclaimed tickets never show on the public attendees list, whatever their tix_username holds. This matches how the private-content check already treats them.

Proposed fix

1. Display fix (small, also hides existing records). In hide_unconfirmed_attendees(), also exclude the placeholder email, the same way the private-content check does:

$query_args['meta_query'][] = array(
	'key'     => 'tix_email',
	'value'   => self::UNKNOWN_ATTENDEE_EMAIL,
	'compare' => '!=',
);

It reuses an existing constant and needs no data migration. It also hides the rows already showing on live sites. Named-but-unconfirmed attendees are unaffected.

2. Checkout fix (optional follow-up, only affects new purchases). Make the stored data consistent, so that an unknown attendee always gets the sentinel username, even on row 1:

if ( 1 === $attendee_order && self::UNKNOWN_ATTENDEE_EMAIL !== $attendee->email ) {
	$attendee->username = wp_get_current_user()->user_login;
} else {
	$attendee->username = self::UNCONFIRMED_USERNAME;
}

This also covers the missing server-side check. It changes behaviour that #1480 deliberately widened, so I'd like @dd32's view before doing it. I'm happy to keep it out of the display-fix PR.

Notes

I can open a PR for the display fix.

Activity

  1. added a commit that references this issue on Sep 24, 2026
    a1a1cec
  2. mcliwanow commented on Sep 24, 2026

    @mcliwanow
    Contributor

    #2117 is deployed, but https://asia.wordcamp.org/2027/attendees/ still lists two "Unknown Attendee" entries.

    Asia 2027 has 14 attendees named Unknown Attendee. 11 of them have the placeholder email and the [[ unconfirmed ]] username, so the username check was already hiding them before #2117. The other 3 are the buyer's own row, with the buyer's real email and username, and neither check catches that. The newest one was created today.

    The email is real because add_unknown_attendee_info_stubs() fills each field separately: the name only when it's empty, the email only when it isn't a valid address. Row 1 comes pre-filled with the buyer's email by prepopulate_known_fields(), and the name is often empty because many profiles don't have one. So the buyer ends up with the stub name and their own email.

    I think we need option 2 after all, but it also has to handle the email: when unknown_attendee is set, store the placeholder email and the sentinel username whatever was pre-filled, or reject unknown_attendee on the buyer's row on the server. Existing rows will need a one-off fix as well.

  3. bor0 commented on Oct 8, 2026

    @bor0
    Member

    #2149 is deployed. It fixes new purchases: ticking "I don't know who will use this ticket yet" on the buyer's own row now stores the ticket as an unknown attendee, and the buyer also gets the claim link to forward. Here's what I did about the tickets created before that.

    1. Swept every camp (read-only)

    I ran the script below with WP-CLI against production, once, for the whole network. For each site with CampTix tickets, it counts the tickets that have the "Unknown Attendee" name next to a real email and a real username. It matches the English name and the site's own translation of it. It writes nothing and prints no personal data.

    wp eval-file sweep-2114.php --url=central.wordcamp.org
    
    sweep-2114.php
    <?php
    /**
     * Read-only sweep for https://github.com/WordPress/wordcamp.org/issues/2114.
     *
     * Counts, on every site, the tickets that the repair script in #2149 would pick up: the unknown attendee
     * name next to a real email and a real username. Matches the English stub name and the site's own
     * translation of it. Writes nothing and prints no personal data.
     *
     *     wp eval-file sweep-2114.php --url=central.wordcamp.org
     */
    
    defined( 'ABSPATH' ) || die();
    
    global $wpdb;
    
    $placeholder = 'unknown.attendee@example.org';
    $unconfirmed = '[[ unconfirmed ]]';
    $rows        = array();
    $scanned     = 0;
    
    foreach ( get_sites( array( 'number' => 0, 'fields' => 'ids', 'deleted' => 0 ) ) as $blog_id ) {
    	$posts_table = $wpdb->get_blog_prefix( $blog_id ) . 'posts';
    
    	// Skip sites that never had CampTix, without switching to them.
    	if ( ! $wpdb->get_var( "SELECT 1 FROM $posts_table WHERE post_type = 'tix_attendee' LIMIT 1" ) ) {
    		continue;
    	}
    
    	switch_to_blog( $blog_id );
    	$scanned++;
    
    	$locale = get_option( 'WPLANG' ) ?: 'en_US';
    	switch_to_locale( $locale );
    	$first_names = array_unique( array( 'Unknown', __( 'Unknown', 'wordcamporg' ) ) );
    	$last_names  = array_unique( array( 'Attendee', __( 'Attendee', 'wordcamporg' ) ) );
    	restore_previous_locale();
    
    	$in_first = implode( ',', array_fill( 0, count( $first_names ), '%s' ) );
    	$in_last  = implode( ',', array_fill( 0, count( $last_names ), '%s' ) );
    
    	$found = $wpdb->get_row( $wpdb->prepare(
    		"SELECT COUNT( DISTINCT p.ID ) AS broken, MIN( p.post_date ) AS oldest, MAX( p.post_date ) AS newest
    		FROM $wpdb->posts p
    		JOIN $wpdb->postmeta fn ON fn.post_id = p.ID AND fn.meta_key = 'tix_first_name' AND fn.meta_value IN ( $in_first )
    		JOIN $wpdb->postmeta ln ON ln.post_id = p.ID AND ln.meta_key = 'tix_last_name'  AND ln.meta_value IN ( $in_last )
    		JOIN $wpdb->postmeta em ON em.post_id = p.ID AND em.meta_key = 'tix_email'      AND em.meta_value != %s
    		JOIN $wpdb->postmeta un ON un.post_id = p.ID AND un.meta_key = 'tix_username'   AND un.meta_value NOT IN ( '', %s )
    		WHERE p.post_type = 'tix_attendee' AND p.post_status IN ( 'publish', 'pending' )",
    		array_merge( $first_names, $last_names, array( $placeholder, $unconfirmed ) )
    	) );
    
    	if ( $found && $found->broken > 0 ) {
    		$rows[] = array(
    			'blog_id' => $blog_id,
    			'url'     => home_url( '/' ),
    			'locale'  => $locale,
    			'broken'  => (int) $found->broken,
    			'oldest'  => $found->oldest,
    			'newest'  => $found->newest,
    		);
    	}
    
    	restore_current_blog();
    }
    
    if ( $rows ) {
    	WP_CLI\Utils\format_items( 'table', $rows, array_keys( $rows[0] ) );
    }
    
    WP_CLI::log( sprintf(
    	'%d site(s) with CampTix scanned, %d with affected tickets, %d ticket(s) in total. Nothing was changed.',
    	$scanned,
    	count( $rows ),
    	array_sum( wp_list_pluck( $rows, 'broken' ) )
    ) );

    It scanned 1,266 sites and found 38 tickets on 22 camps. The oldest was from May 2025 and the newest from 7 October, before the deploy. Asia 2027 had the 3 mcliwanow found, and none have been added since.

    Camp Tickets
    us.wordcamp.org/2025 4
    gdynia.wordcamp.org/2025 4
    europe.wordcamp.org/2026 4
    asia.wordcamp.org/2026 3
    bangkok.wordcamp.org/2025 3
    asia.wordcamp.org/2027 3
    events.wordpress.org/campusconnect/2026/smk-bim-jakarta 2
    kansai, lithuania, malaysia, sofia (2025); nice, slovenia, portugal, vienna, madrid, us, belgrade, netherlands, kathmandu (2026); events.wordpress.org/bhopal/2025/wpbuildtour, events.wordpress.org/kozhikode/2026/WP-Future 1 each

    2. Repaired them with the script from #2149

    For each camp I ran the repair script from the #2149 description in list mode first, checked the count matched the sweep, then ran it with convert:

    wp eval-file repair-2114-unknown-buyer-rows.php --url=<camp>
    wp eval-file repair-2114-unknown-buyer-rows.php convert --url=<camp>
    

    convert stores each ticket the way checkout now does: the placeholder email, [[ unconfirmed ]] as the username, and the buyer's username in tix_buyer_username. The ticket leaves the public attendees list, e-mails about it still go to the buyer, and whoever ends up using it can claim it. Each change is in the CampTix log on the attendee.

    The three events.wordpress.org camps needed one extra step. Under WP-CLI, --url=events.wordpress.org/<city>/<year>/<type>/ silently resolves to the root events site, because sunrise-events.php (which matches those multi-level paths) skips itself on the CLI. The first run there did nothing. I forced the lookup with a --require file:

    printf '%s\n' '<?php' "WP_CLI::add_wp_hook( 'site_by_path_segments_count', function () { return 3; } );" > segments.php
    wp --require=segments.php eval-file repair-2114-unknown-buyer-rows.php convert --url=events.wordpress.org/bhopal/2025/wpbuildtour/
    

    3. Swept again

    The same sweep now finds 1 ticket. 37 of 38 are repaired.

    The one left is on us.wordcamp.org/2026. CampTix isn't active on that site anymore, so the repair script can't load there, and the attendees page doesn't render the list either. I left it alone.

    Notes

    • The public attendees list is cached for up to 3 hours, or a day on archived camps, so a few converted tickets may still show for a while.
    • convert doesn't remove the "attending" entry the participation notifier added to the buyer's profile at purchase.

    Closing this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions