Configure an Event
Overview
This procedure sets up the structure of a new event: the event record, its event_category
rows, its event_race_type rows, the course per distance, the race matrix, the
start_group rows, the WooCommerce/admin-service products that price the entries and fees, and
the registration process steps that place a rider into that structure.
It assumes the organisation (tenant) has already been provisioned — see Provision a Registration Portal Tenant for the org, RegistrationSystem, WooCommerce site and default product. This guide covers the per-event configuration on top of that.
|
Today this is a manual procedure run through SQL. It is defined by the outcome it produces, not the tools used. The admin-portal use case E03 Event Setup will automate it; keep this page current as its source of truth. For the entity model and design rationale behind these tables, see Event / Race / Participant Design, Event Entities, Race Configuration: Rounds & Heats, and EventCategory Cascading Inheritance. |
The event structure
Event
├── EventCategory (one per entry class — as fine-grained as possible)
├── EventRaceType (Event ↔ RaceType join, e.g. Road, MTB, E-Bike)
├── Course (one per distance)
└── Race = EventCategory × EventRaceType (+ Course)
└── StartGroup (one or more; combine categories that start together)
Two rules drive the whole design:
-
An
EventParticipanthas exactly oneEventCategorybut manyEventRaceType\s. -
A
Raceis the matrix intersection of oneEventCategoryand oneEventRaceType(plus aCourse).
|
Make categories as fine-grained as possible. It is always easy to combine categories into a
shared StartGroup or Race later, but very hard to split participants back out of a category. So
create e.g. |
Database and access
Event structure lives in the admin-service Event DB (wpca_prod on the idealogic-prod cluster).
Reach it by SSH tunnel — see the
Number & Tag Runbook for the tunnel recipe:
mysql -h 127.0.0.1 -P 3307 -u claude -p"$(cat ~/dev/ems/claude.pwd)" wpca_prod
Run the whole set of inserts inside a transaction and use LAST_INSERT_ID() (captured into
@-variables) to wire the foreign keys, so a mistake rolls back cleanly.
Prerequisites
-
The organisation is provisioned (tenant guide).
-
Event details: name, start/end date-time, timezone, venue, and the owning
organisation_id. -
The category list (each entry class you will offer), the race types (Road / MTB / E-Bike / …), and the distances.
-
CSA rules for the event (membership / licence / day-licence policy) if it is a sanctioned event.
E1. Create the Event
|
Clone a comparable prior event and change the details, rather than building the row from
scratch. The |
Required fields (NOT NULL)
Every field here must be set for a valid event row.
| Column | Type | Notes |
|---|---|---|
|
varchar(100) |
Event name. |
|
varchar(50) |
IANA zone, e.g. |
|
datetime |
Event start (zoned). |
|
datetime |
Event end (zoned). |
|
bit |
All-day event flag. |
|
FK → organisation |
The organising organisation (the tenant/owner). |
|
FK → organisation |
The sanctioning body (may equal the organiser). |
|
bit |
Entry requires CSA membership. |
|
bit |
Entry requires a CSA licence. |
|
bit |
Accept a UCI licence (default |
|
bit |
Enforce strict CSA validation. |
|
bit |
Block the day-licence option ( |
|
bit |
Custom list 1 mandatory (default |
|
bit |
Custom list 2 mandatory (default |
|
bit |
Custom list 3 mandatory (default |
|
bit |
Auto-allocate race numbers at registration (default |
|
varchar(20) |
Enum, default |
Key optional fields
Not NOT NULL, but commonly set — and part of the wizard’s field set.
| Column | Type | Notes |
|---|---|---|
|
varchar |
Location. |
|
decimal(10,6) |
Geocode. |
|
varchar |
Public contact. |
|
varchar |
Listing metadata. |
|
char(1) |
|
|
FK → series |
Series the event belongs to; inherits Series defaults (products, categories). |
|
FK |
Number/tag inventory types for this event. |
|
FK → registration_system |
Registration system the event’s entries come through. |
|
FK → product |
Default entry product (overrides the Series default; see E7). |
|
FK → product |
Fees for first / additional race number. |
|
FK → product |
Fees for first / additional tag. |
|
FK → product |
CSA day-licence product (see E7.3). |
|
varchar(64) |
CSA’s own id for this event, required to issue day licences and sent with CSA checks (see E7.4). |
|
date |
The date every age for the event is calculated on. Under CSA rules this is 31 December of the race year, e.g. |
|
FK / varchar |
Custom lists + their display names. |
|
FK → process_definition |
The registration ProcessDefinition driving the entry form. |
disciplines |
M:N via |
Road (1) / MTB (2) / Track (3). |
Representative insert
INSERT INTO event
(name, timezone, start_date_time, end_date_time, all_day,
organiser_id, sanctioning_organisation_id,
csa_membership_required, csa_license_required, allow_uci_license,
csa_strict_registration, csa_day_license_disallow,
custom_list_1_required, custom_list_2_required, custom_list_3_required,
auto_assign_numbers_on_registration, preferred_timing_identifier)
VALUES (
'<Event name>', 'Africa/Johannesburg',
'<YYYY-MM-DD HH:MM:SS>', '<YYYY-MM-DD HH:MM:SS>', b'0',
<organiser_org_id>, <sanctioning_org_id>,
b'0', b'0', b'0', b'0', b'0', -- CSA flags
b'0', b'0', b'0', -- custom-list requirements
b'0', 'PERSON_ID' -- auto-assign numbers; timing identifier
);
-- note the generated id → <event_id>
The CSA flags drive registration policy: csa_membership_required / csa_license_required
gate entry on CSA membership/licence, csa_day_license_disallow blocks the day-licence option, and
csa_strict_registration enforces strict validation. They determine whether the CSA day-licence
product (E7.3) applies.
|
E2. Create the EventCategories
One event_category per entry class, as fine-grained as possible (see the tip above). The name
matters: the participant importer matches event_category.name exactly (trimmed), so it must
equal the category string produced by your import spreadsheet.
INSERT INTO event_category
(name, event_id, owner_organisation_id, entry_category, race_category, gender, min_age, max_age)
VALUES
('<Category name>', <event_id>, <organisation_id>, b'1', b'0', NULL, NULL, NULL);
-
entry_category = b'1'— the category can be selected for new entries. -
race_category— whether the category is used as a combined race category; set per your race design. -
gender('M'/'F'),min_age,max_ageare optional age/gender restrictions.
E3. Create the EventRaceTypes
event_race_type joins the Event to global race_type codes (Road, MTB, E-Bike, …). One row per
race type the event uses; unique on (race_type_id, event_id).
INSERT INTO event_race_type (name, race_type_id, event_id)
VALUES ('<Race type name>', <race_type_id>, <event_id>);
|
The global |
E4. Create the Courses
One course per distance.
INSERT INTO course (name, distance_meters, event_id)
VALUES ('<Distance label e.g. 120km>', <metres>, <event_id>);
|
|
E5. Create the Races
Each race is the intersection of one EventCategory, one EventRaceType, and one Course.
Create one race per category. Same-distance categories that ride together (e.g. MTB 55km and
E-Bike 55km) share a Course but are separate Races — that is the point of fine-grained
categories.
INSERT INTO race
(name, start_time, active, status, event_race_type_id, course_id, event_category_id, deleted, closed)
VALUES
('<Race name>', '<YYYY-MM-DD HH:MM:SS>', b'1', 'PLANNED',
<event_race_type_id>, <course_id>, <event_category_id>, '', '');
E6. Create the StartGroups
A start_group puts participants on the start line. Create one per race for a simple mass-start
event. To start several categories together, point their StartGroups at a shared start
time/programme (or model them under one StartGroup) — this is how fine-grained categories are
combined without merging the categories themselves. StartGroups are ordered on the programme via a
program_entry.
INSERT INTO program_entry (name, program_type, active, date_time, event_id)
VALUES ('<Race name>', 'EVENT', b'1', '<YYYY-MM-DD HH:MM:SS>', <event_id>);
-- @pe := LAST_INSERT_ID()
INSERT INTO start_group (start_time, seq, race_id, program_entry_id)
VALUES ('<YYYY-MM-DD HH:MM:SS>', 1, <race_id>, @pe);
E7. Event registration products and fees
Each priced item — every entry category and every fee (e.g. a CSA day licence) — is a WooCommerce
product on the organisation’s site, mirrored by an admin-service product row and linked to the
category.
E7.1 Create the WooCommerce products
Create one Simple product per entry category, one per fee, on the organisation’s WooCommerce site. For each:
-
Type — Simple product.
-
Virtual — tick it (entries, memberships and fees are not shipped).
-
Name and description — required; appears on the order/invoice.
-
Inventory → "Limit purchases to 1 item per order" — tick it. Quantity is always 1 because the participant’s metadata is attached to each individual line item.
-
Slug — optional but recommended.
-
Product image — optional, recommended.
Group them with Product categories — e.g. an Entries category for the event entries, and a CSA category for the Cycling South Africa day licence (the fee required from participants who are not members of the national federation at a sanctioned event).
|
Hide entries and fees from the storefront. These products are added to orders programmatically, so they should never appear in the public shop or search. On each product, in the Publish panel click Edit next to Catalog visibility and choose Hidden, then Update. WooCommerce’s four options are Shop and search results (default), Shop only, Search results only, and Hidden. "Hidden" still lets the product be reached by direct link and added to an order — exactly what the plugin does. |
E7.2 Create the admin-service products and link to categories
Mirror each WooCommerce product with an admin-service product row whose external_id holds the
WooCommerce product ID, then link the product to its EventCategory via event_category.product_id.
INSERT INTO product (name, external_id, price)
VALUES ('<Category / fee name>', '<woocommerce-product-id>', <price>);
-- @prod := LAST_INSERT_ID()
UPDATE event_category SET product_id = @prod WHERE id = <event_category_id>;
|
The |
E7.3 Wire the CSA day licence
The day-licence fee needs one extra link beyond E7.2. Create its product row as in E7.2 (the
external_id is the day-licence WooCommerce product ID), then point the Event at it — or the
Series, which every event inherits from:
INSERT INTO product (name, external_id, price)
VALUES ('CSA Day License', '<woocommerce-product-id>', <price>);
-- @dl := LAST_INSERT_ID()
UPDATE event SET product_day_license_id = @dl WHERE id = <event_id>;
-- or, to cover every event in a series:
UPDATE series SET product_day_license_id = @dl WHERE id = <series_id>;
The event-level value wins; the series is the fallback. The same link can be set through the REST
API (PUT /api/events, PUT /api/series — field productDayLicense) from admin-service 2.4.2
onwards, which is when it was first exposed on the DTO.
Without this link, riders can still select the day-licence option during registration, but no day-licence line item is added to the order — admin-service logs a warning instead, so the rider registers without ever being charged.
|
The product link alone is not enough — the event must use the process-flow registration.
A day licence is only ever added because a rider was found non-compliant at the CSA identification
and compliance steps ( So the full prerequisite list is:
Verify with:
|
|
Two operational caveats once the day licence is wired. The WooCommerce site must run event-payment-plugin 2.2.0 or later. A day-licence line is sent
without a category (that absence is what makes it render as a licence rather than a second event
entry). Earlier plugin versions reject any participant line with no category, failing the whole
order with Billing and issuance are separate. The rider is charged the fee as soon as the order is created,
but the licence is only registered with CSA after payment, and only when
|
E7.4 Set the CSA event id
CSA identifies each race by its own event id, and a day licence can only be issued against it. The
id belongs to the event: there is no Series value and no global setting (the former
csa.api.csa-event-id property was removed). Obtain the id from CSA for this specific race, then set
it on the event. Until an event setup screen exists, set it over REST:
PATCH /api/events/<event_id>
Content-Type: application/merge-patch+json
{ "id": <event_id>, "csaEventId": "<csa-event-id>" }
or directly:
UPDATE event SET csa_event_id = '<csa-event-id>' WHERE id = <event_id>;
What the id changes:
-
Day-licence issuance. With no CSA event id, no licence is issued and no CSA call is made. The rider is still billed the day-licence fee, and is flagged for follow-up instead: they appear under
GET /api/event-participants?adminReviewReason.equals=DAY_LICENSE_NOT_ISSUED, withdayLicenseFailure = NO_EVENT_ID. -
CSA membership and licence checks (
CSI,CSM,CSL) send the event’s id ascsa_event_id. They still run when it is not set, as they always have. Once it is set, CSA evaluates the check against that event for the first time — confirm the checks behave as expected on dev with the id set before relying on it for a live event.
The E7.3 verify query returns the event’s csa_event_id.
E8. Configure the registration process
The entry form a rider completes on the registration portal is driven by the process_definition
that event.enrol_process_id points at (process_definition.type = 'E'). Each process_step
is one question, shown in seq order. Two step types put a rider into the E2–E6 structure:
type |
Question | Behaviour |
|---|---|---|
|
Event category |
Single choice. It lists the event’s categories with |
|
Race types |
Multiple choice. It lists the event’s |
When the registration completes, the rider’s event_participant gets the chosen category. For
each chosen race type, the rider joins the start_group of the race for that category and race
type. The matrix you built in E5 therefore decides what a rider can enter. A category × race type
pair with no race row cannot be selected.
|
Put Either order works. Each step narrows its options by the other’s answer only when that answer was given earlier, and re-checks its own saved answer whenever it is shown again. With race types first, the category list offers only categories that run every chosen race type, and says so when none does. After going back to change the category, a race type the new category does not run is removed from the selection and named to the rider. Earlier releases dropped the extra race types silently at completion, or rejected the revisit with "Selected race type is not available" until the rider restarted; both are fixed. Whatever the order, the CSA steps ( |
Other rules to observe:
-
Without an
ECAstep, no start groups are assigned — even ifERTis present. Start groups are looked up through the chosen category’s races. -
Keep one
start_groupperracefor events that register through the form. When a race has several, the one the rider is placed in is not predictable. This matters for track cycling, where every heat is a start group of the same race: seed riders into heats after registration. -
Age is taken on the event’s
age_calculation_date(E1) — 31 December of the race year for a CSA event. The category step, the eligibility check on the person screen, the change-category dialog and the participant import all use it, so they agree on a rider’s category. Leave it null and the category step falls back to the rider’s age on the day they register: a rider with a birthday between registering and the reference date can then land on the other side of an age boundary. A rider without a date of birth counts as age 0, so only categories with nomin_ageare offered. -
Limit the number of race types with
value_min/value_maxon theERTstep (for example,value_max = 3). Setjhi_require = b'1'to require at least one. -
One entry fee per rider, whatever the number of race types. It comes from the category’s product (E7.2) or the event’s default product.
Check the step order:
SELECT ps.seq, ps.type, ps.name,
CAST(ps.jhi_require AS UNSIGNED) AS required, ps.value_min, ps.value_max
FROM event e
JOIN process_step ps ON ps.definition_id = e.enrol_process_id
WHERE e.id = <event_id>
ORDER BY ps.seq;
ECA before ERT is the recommended order; the CSA steps must follow both and ITC must be
last. Also check that every category and race type pair you intend to offer has a race row (E5).
Worked example: Event 144 — Rooibos MTB (2026)
Fine-grained categories, one Course per distance, four EventRaceTypes, one Race per category (same-distance MTB/E-Bike share a course but are separate races). Modelled on event 113 (Rooibos MTB 2025).
EventCategories (event_category.name, entry_category=b'1', race_category=b'0',
event_id=144) — these strings must match the import spreadsheet’s Category column verbatim:
-
Gravel - 120km -
MTB - 80km -
MTB - 55km -
MTB - 30km -
E-Bike - 55km -
E-Bike - 30km -
Single Start - Wine Walk
Courses: 120km (120000 m), 80km, 55km, 30km, Wine Walk.
EventRaceTypes: MTB (race_type 20), Gravel (21), E-Bike (24), Single Start (23).
Races (one per category = EventCategory × EventRaceType × Course; active=b'1',
status='PLANNED'): Gravel 120km, MTB 80km, MTB 55km, E-Bike 55km, MTB 30km, E-Bike 30km,
Single Start Wine Walk — with a program_entry (program_type='EVENT') and one seq=1
start_group per race, staggered from 08:00.
The full script is ~/dev/ems/.local/rooibos-2026/event144-structure.sql, run with:
mysql -h 127.0.0.1 -P 3307 -u claude -p"$(cat ~/dev/ems/claude.pwd)" wpca_prod < event144-structure.sql
That script inserts race_type id 24 (E-Bike, discipline 2) and uses the legacy
course.dist column — both are prod-only and not reproducible from the repo seed data.
|
Related
-
Provision a Registration Portal Tenant — the org/tenant setup this builds on.
-
Event / Race / Participant Design — the entity model and rationale.
-
Event Entities — the entity reference catalogue.
-
Race Configuration: Rounds & Heats — how the config model maps from simple to complex events.
-
E03 Event Setup — the admin-portal use case that will automate this procedure.
-
Forms — the process-step framework behind the registration form (E8).
-
Track Cycling Configuration — races with many start groups (heats across rounds).