Custom Lists: Design and UserMeta Persistence

Overview

CustomList and CustomListValue provide organisation-scoped lookup values that events can attach to each EventParticipant in up to three configurable slots (custom_1_id, custom_2_id, custom_3_id). Typical uses are Club, Team, School, and any other organisation-specific classification the operator wants to capture at registration.

This document covers:

  • the two entities and their relationships,

  • how an Event configures which slots are used and which CustomList populates each,

  • how a selection made during registration is both stored on the EventParticipant and persisted to the Person via wp_usermeta, so the value can be carried forward automatically to the rider’s next event.

Throughout this document Person and User refer to the same natural person. The current backing table is wp_users + wp_usermeta (the WordPress pattern); the wrapper abstraction is PersonWrapper. The forthcoming Person entity (see the migration note on that page) will replace this pair without changing the semantics described here.

Entities

CustomList

Organisation-scoped container of lookup values.

Field Purpose

id

Primary key

name

Unique internal name of the list (e.g. "WP Clubs 2026")

display_name

Label shown on-screen or in reports (e.g. "Club")

display_code

Y/N — whether the CLV’s short code column is shown in pickers

meta_key

Key into wp_usermeta. The authoritative mapping between a rider’s current value (held as a UserMeta row) and this list. See UserMeta Persistence.

organisation

Owning Organisation

CustomList implements OrganizationalSecured — access is scoped by organisation.

CustomListValue

An entry within a single list.

Field Purpose

id

Primary key

name

Authoritative human-readable name. Used as the meta_value in wp_usermeta (see UserMeta Persistence).

code

Optional short code (e.g. "WPCC"). Used as a filter or in condensed displays when display_code = Y on the parent list.

list

Parent CustomList

CustomListValue is not directly organisation-secured — it inherits its access scope from its parent CustomList.

Event configuration

Event exposes three slots. Each slot has three config attributes:

Slot Column Meaning

1

custom_list_1_id

Pointer to the CustomList that backs slot 1

1

custom_list_1_name

Display label for this slot on forms and reports (may differ from the list’s own display_name)

1

custom_list_1_required

Y/0/N — whether registration must capture a value

2

custom_list_2_id

Pointer for slot 2

2

custom_list_2_name

Display label for slot 2

2

custom_list_2_required

Required flag for slot 2

3

custom_list_3_id

Pointer for slot 3

3

custom_list_3_name

Display label for slot 3

3

custom_list_3_required

Required flag for slot 3

If custom_list_{n}_id is NULL, slot n is unused on that event. An event can use any subset of the three slots; the slot index is just a positional identifier, not a stable meaning across events.

EventParticipant storage

For each slot the Event has configured, the rider’s selection is stored as a foreign key on the EventParticipant:

  • custom_1_idCustomListValue.id

  • custom_2_idCustomListValue.id

  • custom_3_idCustomListValue.id

Each of these is optional; NULL is valid when the corresponding slot is unused or not required. The reference must point to a CustomListValue whose parent CustomList matches the event’s configured custom_list_{n}_id. The integrity of this relationship is a registration-flow invariant, not a database constraint.

UserMeta Persistence

CustomList.meta_key is the linchpin that lets a rider’s current club/team/school/etc. persist across events and get pre-populated on the next registration without the rider having to re-enter it.

The contract

  1. On registration or EP creation (including EP import), for every slot the Event has configured with a custom_list_{n}_id:

    • Resolve the CustomList and read its meta_key.

    • Upsert wp_usermeta(user_id = person.id, meta_key = CustomList.meta_key, meta_value = CustomListValue.name).

    • The upsert is "latest wins" — a new selection overwrites any earlier value stored under the same meta_key for the same person.

  2. On EP creation for a subsequent event that also uses a slot configured with a CustomList:

    • Read the CustomList.meta_key for each configured slot.

    • Pull the current wp_usermeta.meta_value for (user_id, meta_key).

    • Find a CustomListValue on the currently-configured list where name = meta_value.

    • If found, pre-populate EventParticipant.custom_{n}_id with that CLV’s id.

    • If no UserMeta row exists, or no name match is found on the currently-configured list, leave custom_{n}_id = NULL.

meta_value is the CLV name, not the id

The value stored in wp_usermeta is the CustomListValue.name, not its id. Two consequences:

  • Names are human-readable — the value stays meaningful if the admin ever inspects the raw wp_usermeta table, and it matches the convention used by the legacy wpca_members plugin.

  • List PKs can change freely between seasons. The organisation commonly creates a new CustomList per season (e.g. "WP Clubs 2025""WP Clubs 2026") to keep the roster clean. Because the UserMeta row points to a name, a rider whose club name is unchanged between seasons will be matched against the new list transparently. Only when the club name itself changes (a rename, a merger) does the lookup miss.

Unmatched lookups

If Phase 2 cannot find a CustomListValue on the current event’s list with name = meta_value, the EP field is left NULL. Deliberate choices:

  • No auto-creation of CLV rows. The list is curated; introducing a row because a UserMeta value does not match could quietly propagate mis-spellings or obsolete clubs.

  • No error. A registration flow that blocks on an unmatched historical value would be hostile; the rider can simply pick again from the current list.

  • Observable. The EP import response surfaces an unset-but-expected slot in its issues list so an operator can triage.

Stages where the contract applies

The contract fires wherever an EventParticipant row is created or has its custom_{n}_id set:

  • The registration flow (EventFormController.onDone() and equivalents).

  • The EP import path (EventParticipantImportXLS.readStream()).

  • The admin EP edit screen, when a slot value is changed.

The save on EventParticipant and the upsert on wp_usermeta are in the same transaction so the two writes cannot diverge.

One-directional flow

The contract applies forward only:

  • EP change → UserMeta upsert: yes.

  • UserMeta edit (from an admin UI or elsewhere) → propagate back into existing EPs: no.

An admin who wants to correct a rider’s club on an already-created EP edits the EP directly. This keeps the flow predictable and avoids "hidden" changes to historical events when a rider updates their profile.

Worked example

WPCA 2026 Autumn Road League:

  • Organisation: WP Cycling (organisation_id = 4).

  • Two lists configured across the series:

    • CustomList id 1504"WP Clubs 2026", meta_key = "club_name".

    • CustomList id 1505"WP Teams 2026", meta_key = "team_name".

  • Each Road League Event has:

    • custom_list_1_id = 1504, custom_list_1_name = "Club", custom_list_1_required = "0" (optional).

    • custom_list_2_id = 1505, custom_list_2_name = "Team", custom_list_2_required = "N" (optional).

Rider Jane Doe registers for event 119 (Duynefontein) and picks "WPCC" as her club and "Gravel Devils" as her team:

  • EventParticipant.custom_1_id is set to the CustomListValue with name = "WPCC" on list 1504.

  • EventParticipant.custom_2_id is set to the CustomListValue with name = "Gravel Devils" on list 1505.

  • wp_usermeta for Jane is upserted with:

    • (meta_key = "club_name", meta_value = "WPCC"),

    • (meta_key = "team_name", meta_value = "Gravel Devils").

Two weeks later Jane registers for event 120 (Redhill), which is configured identically. The registration flow reads Jane’s wp_usermeta and pre-populates both slots — no manual entry. If the Road Commissioner later creates "WP Clubs 2027" (a new CustomList with meta_key = "club_name" and a fresh PK) that still contains a CLV named "WPCC", the same carry-forward works across the seasonal list swap because the lookup is by name.

If Jane changes her club to "BMT" at event 120, the registration flow:

  • writes the new custom_1_id on the new EP,

  • overwrites the club_name UserMeta row with "BMT" (latest wins),

  • does not touch her existing EP for event 119 (which still shows "WPCC" — the record of how she raced that day).