Import: Event Participant

Related reading

1. Overview

The Event Participant import registers (or updates) participants in a named event from an XLSX file. Each row carries up to four identity inputs (our EP id, our Person id, the source system’s entry key, the source system’s person id) and is resolved through the participant-identity model — so the same file can be re-run with corrections without creating duplicate people or entries. The source system’s entry key is stored as the Participant-XID (EventParticipant.registrationId); on a confirmed match against a foreign system the source’s person id is written back as a Person-XID (PersonExternalReference). Each row produces one of five outcomes (CREATED / UPDATED / SKIPPED / WARNING / ERROR), and the aggregated response is returned as EventParticipantImportResultDTO.

The HTTP surface is asynchronous from release 2.4: the upload returns HTTP 202 immediately and the caller polls a companion GET endpoint for the result DTO.

2. API

2.1. Endpoint Reference

Endpoint Purpose

PUT /api/event-participants/import

Upload an XLSX and start the async import. Returns HTTP 202 Accepted with an ImportJobDTO — the job is queued on the generic async framework.

GET /api/event-participants/import/{jobId}

Retrieve the EventParticipantImportResultDTO once the job reaches COMPLETED or FAILED. Returns HTTP 409 while still processing; HTTP 404 for unknown ids or wrong importType.

2.2. Async Delivery Mode

The per-entity endpoint is a thin facade over the generic async framework. Behind the scenes EventParticipantRowProcessor runs in whole-file mode, delegating to EventParticipantImportXLS.process(…​) — the same bulk importer that produced the response DTO inline in the pre-async implementation. The resulting EventParticipantImportResultDTO is JSON-serialised to ImportJob.resultPayloadJson and echoed back verbatim by the GET endpoint. Response shape is byte-identical to what the prior synchronous PUT returned inline — only the delivery mode changed.

PUT /api/event-participants/import
  ?eventId={long}                                    (required)
  &orgId={long}                                      (optional — defaults to caller's tenant)
  &sheetIndex={int}                                  (default: 0)
  &createCustom1={true|false}                        (default: true)
  &createCustom2={true|false}                        (default: true)
  &createCustom3={true|false}                        (default: true)
  &sourceSystemId={long}                             (optional — the external source registration system)
  &trustPKs={true|false}                             (default: false)
  &updatePII={true|false}                            (default: false)
  &acknowledgeFingerprintWarning={true|false}        (default: false)
  Content-Type: multipart/form-data
  Body: file=<xlsx>

HTTP/1.1 202 Accepted
Location: /api/event-participants/import/{jobId}
{ "identifier": "<jobId>", "status": "PROCESSING", "skipMappingPhases": true, ... }

# operator polls ...

GET /api/event-participants/import/{jobId}

HTTP/1.1 200 OK                     # once COMPLETED or FAILED
{ ... EventParticipantImportResultDTO ... }
HTTP/1.1 409 Conflict               # while still processing

See Import Operations Runbook for the polling procedure.

2.3. Request Body

Multipart form with a single file part containing the XLSX workbook. One sheet per upload; sheetIndex selects which sheet (0-based, default 0).

Query parameters:

  • eventId (required) — target event; scopes the registrationId upsert

  • orgId (optional) — override the caller’s tenant; normally inferred from the event

  • sheetIndex (default 0) — 0-based sheet index within the workbook

  • createCustom1/2/3 (default true) — if true, a missing value in CUSTOM_LIST_1/2/3 is auto-created on the corresponding CustomList; if false, missing values produce a WARNING without creating the custom value

  • sourceSystemId (optional) — the external source registration system the file came from. Drives the (is_self, trustPKs) trust matrix and the Person-XID write-back, and is recorded set-if-absent onto Event.sourceRegistrationSystem so result import can default it (US #769). Omit for a self/our-own import.

  • trustPKs (default false) — when the source is a foreign system, assert that the file’s foreign PKs are reliable enough to resolve by directly. When false, foreign PKs are hints and the importer falls through to identity matching + the fingerprint check. See the trust matrix.

  • updatePII (default false) — when true, an existing person’s PII (name, DOB, contact) may be overwritten from the file; when false, PII is only filled where blank.

  • acknowledgeFingerprintWarning (default false) — set after an operator has reviewed and accepted a flagged fingerprint mismatch, allowing the otherwise-blocked link to proceed.

2.4. Response: EventParticipantImportResultDTO

{
  "totalRows": 185,
  "created": 20,
  "updated": 160,
  "skipped": 0,
  "warnings": 4,
  "errors": 1,
  "issues": [
    { "outcome": "WARNING", "firstName": "Jane", "lastName": "Doe",
      "registrationId": "29364", "message": "Ambiguous person match" },
    { "outcome": "ERROR",   "firstName": "Xyz", "lastName": "Abc",
      "message": "CustomList value not found and createCustom1=false" }
  ]
}

Invariant: totalRows == created + updated + skipped + warnings + errors.

The issues list contains only rows with SKIPPED / WARNING / ERROR. Successful CREATED / UPDATED rows are counted but not individually listed, keeping the payload focused on what the operator must action.

2.5. What to Look At

After polling returns the DTO, these are the patterns to confirm the import landed the way you expected:

  • Clean first run against a fresh event: created ≈ totalRows, updated = 0, issues = []

  • Re-run of the same file: created = 0, updated ≈ totalRows, issues = []

  • Correcting a partial first run: some updated + some created is fine; inspect issues for the remainder

The Import Operations Runbook covers the recovery procedures when the numbers don’t match expectations.

3. Column Definitions

The import supports 32 columns, with 2 required fields.

3.1. Required Fields

Column Type Aliases

FIRST_NAME

STRING

FirstName, Name, FN

LAST_NAME

STRING

LastName, Surname, SN, LN

3.2. Person Fields

Column Type Aliases

TITLE

STRING

Title

ID_NUMBER

STRING

ID, IDNumber, IdentityNumber, Passport, IDPassport

ID_TYPE

STRING

IDType (default: NATIONAL)

ID_COUNTRY

STRING (FK)

Country, IDCountry, Nationality (default: ZA)

GENDER

STRING

Gender, Gen, Sex

DOB

DATE

DOB, DateOfBirth, Birthday

CONTACT_NUMBER

STRING

ContactNumber, Cellphone, CellphoneNumber, CellNumber, Telephone, TelephoneNumber, Mobile

EMAIL

STRING

Email, EmailAddress

USER_KEY

LONG

UserKey

MAIN_MEMBER_ID

STRING

MainMember, MainMemberID

PERSON_EXTERNAL_ID

LONG

UID, PID

3.3. Address Fields

Column Type Aliases

ADDRESS

STRING

Address, Address1

TOWN

STRING

Town, City, TownCity, CityTown

POSTAL_CODE

STRING

PostalCode, Code, ZIP

3.4. Event Participation Fields

Column Type Aliases

EVENT_PARTICIPANT_ID

LONG

EPID, EventParticipantID

EVENT_PARTICIPANT_EXTERNAL_ID

STRING

EPREF (also RegistrationID, ExternalID, ExternalEPID) — the source system’s entry key, stored on EventParticipant.registrationId as the Participant-XID. See Identity resolution & upsert.

EVENT_CATEGORY_NAME

STRING (FK)

Category, CategoryName, EventCategory, EventCategoryName

OTHER_NUMBER

STRING

OtherNumber

3.5. Race Number Fields

Column Type Aliases

NUMBER

STRING

Number, Bib, BibNumber, RaceNumber, No

NUMBER_ID

INTEGER

NumberId

TAG_NUMBER

STRING

Tag, TagRef, TagReference, Chip

TAG_ID

INTEGER

TagId

3.6. Order Fields

Column Type Aliases

ORDER_ID

INTEGER

OrderID

ORDER_NUMBER

STRING

OrderNumber, Order#, ReceiptNumber

ORDER_STATUS

STRING

OrderStatus (default: P for Pending)

ORDER_PRODUCT_ID

INTEGER

Product, ProductID, OrderProduct, OrderProductID

ORDER_PRODUCT_AMOUNT

DECIMAL

ProductAmount, OrderProductAmount, Amount

3.7. Custom Fields

Column Type Aliases

CUSTOM_1

STRING

(none)

CUSTOM_2

STRING

(none)

CUSTOM_3

STRING

(none)

CUSTOM_LIST_1

STRING (FK)

Schoolyouareattending (legacy alias; see Outstanding #3)

CUSTOM_LIST_2

STRING (FK)

(none)

CUSTOM_LIST_3

STRING (FK)

(none)

4. Foreign Key Resolution

Fields marked (FK) require resolution to entity IDs:

Field Target Entity Resolution Strategy

ID_COUNTRY

Country

Lookup by code (ZA, US) or name

EVENT_CATEGORY_NAME

EventCategory

Lookup by name within event

CUSTOM_LIST_1/2/3

CustomListValue

Lookup by value within list; auto-create if createCustom{1,2,3}=true

5. Processing Flow

5.1. Row-Level Flow

1. Receive InputStream + orgId + eventId + sheetIndex
2. Load workbook into memory (XSSFWorkbook)
3. Lookup Organisation (tenantService.getOrganisation)
4. Lookup Event (eventService.findOneEntity)
5. For each row:
   a. Skip row 0 (header row, detect columns)
   b. Check if blank (first 5 cells empty)
   c. Create EventParticipantAddRequestDTO
   d. Call eventParticipantService.register()
   e. Collect response
6. Return list of responses, aggregated into EventParticipantImportResultDTO

5.2. Row Extraction

The createRequestDTO method maps spreadsheet values to DTO fields:

EventParticipantAddRequestDTO request = new EventParticipantAddRequestDTO();
request.setId(parseAsLong(row, map, Column.EVENT_PARTICIPANT_ID));
request.setExternalId(parseAsString(row, map, Column.EVENT_PARTICIPANT_EXTERNAL_ID));

PersonDTO person = new PersonDTO();
person.setFirstName(parseAsString(row, map, Column.FIRST_NAME));
person.setLastName(parseAsString(row, map, Column.LAST_NAME));
// ... more fields

5.3. Default Values

defaultValues.put(Column.ID_TYPE, "NATIONAL");
defaultValues.put(Column.ID_COUNTRY, Country.DEFAULT_COUNTRY_CODE);
defaultValues.put(Column.ORDER_STATUS, "P");

5.4. Identity resolution & upsert

Each row is resolved through the participant-identity model rather than a single key. Two grains are resolved in turn — which person and which entry — and the (is_self, trustPKs) matrix governs how much each supplied id is trusted.

Entry (EventParticipant) resolution. The source system’s entry key (RegistrationID, aliases ExternalID / ExternalEPID) is the upsert key within the event — the Participant-XID:

  1. Resolve existing EP via eventParticipantRepository.findByEventIdAndRegistrationId(eventId, registrationId)

  2. If found: wasExisting = true; update fields (outcome = UPDATED)

  3. If not found: create a new EP (outcome = CREATED)

The upsert is scoped to eventId, so the same registrationId recurs across events without collision — the primary enabler for cross-system migration (WPCA → EMS), where legacy PKs are preserved in EventParticipant.registrationId.

Person resolution & Person-XID write-back. A row without a trusted entry key relies on Person matching (our person id if trusted, else identity number, else name+DOB) to avoid duplicate people. When the source is a foreign system and a person is confirmed, the source’s person id is written back as a PersonExternalReference (Person-XID) so the next import resolves that person in one hop. When trustPKs=false, a sampled fingerprint comparison guards the link before it is trusted; a flagged mismatch blocks until acknowledgeFingerprintWarning is set.

Event source system (set-if-absent). The first import from a non-self sourceSystemId records it onto Event.sourceRegistrationSystem if not already set, establishing the default that result import reads back (US #769 — see Participant Identity).

6. Outcome Classification

Every row produces exactly one outcome via the ImportAddResponseDTO.Outcome enum:

Outcome Meaning

CREATED

New EventParticipant (and possibly new Person) created.

UPDATED

Existing EventParticipant matched via registrationId and updated in place.

SKIPPED

Row not processed — missing required field, duplicate within the same file, or other pre-validation failure.

WARNING

EP was created/updated, but a non-fatal issue requires operator review (ambiguous Person match, custom-list value not auto-created, race-number conflict).

ERROR

Exception occurred processing the row; the row was not committed. Subsequent rows continue.

EventParticipantServiceEx.register() classifies the outcome. The inner try-catch sets ERROR outcome with the exception message rather than swallowing.

7. Source Code

File Purpose

EventParticipantImportXLS.java

Main import service: column definitions, row extraction, per-row orchestration.

EventParticipantRowProcessor.java

Whole-file bridge — parses ImportJob.configJson (eventId, sheetIndex, createCustom flags, sourceSystemId/trustPKs/updatePII), delegates to EventParticipantImportXLS.process(…​), serialises the resulting DTO to ImportJob.resultPayloadJson. Its beforeProcessing hook records Event.sourceRegistrationSystem set-if-absent for a non-self source (US #769). See Async Import Architecture — Whole-File Bridge.

EventParticipantServiceEx.java

Registration logic invoked per row; classifies outcome.

EventParticipantResourceEx.java

REST endpoints PUT /import (async upload) and GET /import/{jobId} (result retrieval). Thin facade over ImportJobService.createWholeFileImportJob.

EventParticipantAddRequestDTO.java

Request payload for registration.

EventParticipantAddResponseDTO.java

Response with outcome per row (aggregated into EventParticipantImportResultDTO).

Location: admin-service/src/main/java/za/co/idealogic/event/admin/

8. Outstanding Items

The items below pre-date Feature #442; some were partially addressed during the async cutover. Tracked informally — open an ADO ticket before acting on any of them.

# Topic Status

1

Refactor identity-type parsing (duplicated in MembershipImportXLS) into PersonService.parseIdentityType(String)

Open

2

Refactor gender parsing (duplicated in MembershipImportXLS) into PersonService.parseGender(String)

Open

3

Make CUSTOM_LIST_1 header generic — remove the hardcoded Schoolyouareattending alias or make it organisation-configurable

Open

4

Only create OrderDTO when ORDER_NUMBER or ORDER_ID is present on the row (instead of always instantiating)

Resolved — null-guard added; OrderLineItem is now created only when the corresponding SalesOrder resolves or is created

5

Validate ORDER_STATUS parsing properly (current code uses only the first character)

Open — partial hardening in place

6

Reduce redundant Organisation lookups in the import call stack

Open (performance; not blocking)

7

Reinstate access-control check (filter(e → org.equals(e.getOrganiser()))) on eventService.findOneEntity(eventId)

Open — security gap; validate before closing

8

Replace printStackTrace() swallows in the outer process() catch blocks with a thrown ImportException carrying a meaningful message

Partially resolved — inner try-catch now sets ERROR outcome with exception message; outer process() still prints stack trace (mirror of results-side ADO-454 work)