Import Operations Guide

1. Purpose

This guide is for operators running bulk imports against a deployed admin service. It covers Event Participant, Membership, and Result imports. Scope:

  • Preparing the source file

  • Choosing the right endpoint, mode, and query parameters

  • Running the import against Dev / Stage / Prod

  • Reading the response and recognising common failure modes

  • Recovering from operator errors

2. Quick Reference

All three imports below are documented for release 2.4+ (asynchronous): the upload returns 202 Accepted + job id; poll the paired GET /import/{jobId} for the result DTO. See Async Delivery Mode.

On admin-service through 2.3.32 (current prod), the same endpoints exist but return the full result DTO inline on the PUT — see Sync Mode (pre-2.4) — applies through admin-service 2.3.32.

Each row’s Task cell links to the corresponding design doc’s API section (endpoint spec, request body, response DTO, invariants). The In this guide link jumps to the operational walkthrough below (file prep, curl example, how to read the response, recovery).

Task (design doc) Endpoint Key query params In this guide

Import EPs from spreadsheet (XLSX)

PUT /api/event-participants/importGET /api/event-participants/import/{jobId}

eventId, sheetIndex, createCustom1/2/3

Event Participant Import

Import memberships from spreadsheet (XLSX)

PUT /api/memberships/importGET /api/memberships/import/{jobId}

periodId, sheetIndex, orgId

Membership Import

Import race results from timing (CSV, multi-category)

PUT /api/result-sets/import-bulkGET /api/result-sets/import/{jobId}

eventId, participantIdMode, sourceSystemId, reconciliationScope, pointsCalculator, applyNumberChanges

Result Import (Bulk CSV)

All endpoints require authentication — use the X-API-KEY header with an ADMIN-role key, or a valid JWT from admin-ui.

3. Environment URLs

Refer to Management API Access / reference_environment_urls.md for the current production API key and dev/stage/prod URLs. As of this writing:

Environment Base URL

Production

https://admin-service.idealogic.co.za

Stage

https://admin-service-stage.idealogic.co.za

Dev

https://admin-service-dev.idealogic.co.za

Test against Dev first for any new event or any file type you haven’t imported before. A failed import can delete pre-existing data (see Recovering from a Mode Mismatch). Dev is on the same MySQL cluster as Prod (idealogic-prod) but in the event_admin_service_dev schema, so its behaviour matches prod exactly.

4. Sync Mode (pre-2.4) — applies through admin-service 2.3.32

Production currently runs admin-service 2.3.32 (commit f3273e60, release tag 2.3.32 merged 2026-04-30). The async delivery mode documented in the next section applies from release 2.4 onwards. Until prod is upgraded, the three import endpoints are synchronous: the PUT returns the full result DTO inline. There is no 202 Accepted, no Location header, no jobId, and no GET /import/{jobId} to poll.

Verified anchor: the import code (EventParticipantImportXLS, ResultImportXLS, EventParticipantResourceEx, ResultSetResourceEx) is byte-identical between release 2.3.30 (commit cbde19e6) and the prod commit f3273e60. The intervening 2.3.31 / 2.3.32 commits touch the WPCA points calculator and MembershipStatusMapper respectively, not the import contract.

Review on upgrade: when prod moves to 2.4+, re-diff these four files against the new release tag and either delete this section or refresh its anchor.

4.1. Sync curl patterns

EP import (sync):

curl -X PUT \
  -H "X-API-KEY: ${API_KEY}" \
  -F "[email protected]" \
  "$BASE_URL/api/event-participants/import?eventId=<id>&sheetIndex=0&createCustom1=true&createCustom2=true&createCustom3=true"

Returns 200 OK + EventParticipantImportResultDTO inline.

Membership import (sync):

curl -X PUT \
  -H "X-API-KEY: ${API_KEY}" \
  -F "[email protected]" \
  "$BASE_URL/api/memberships/import?periodId=<id>&sheetIndex=0"

Returns 200 OK + MembershipImportResultDTO inline.

Bulk result import (sync):

curl -X PUT \
  -H "X-API-KEY: ${API_KEY}" \
  -F "[email protected]" \
  "$BASE_URL/api/result-sets/import-bulk?eventId=<id>&participantIdMode=regid&pointsCalculator=wpca-road-league"

Returns 200 OK + BulkResultImportResponseDTO inline.

The DTO shape returned by the sync PUT is byte-identical to the DTO returned by the async GET /import/{jobId} once the job completes — see the per-import sections below for field-level interpretation. The only thing that changes between sync and async is how the DTO is delivered.

4.2. Sync mode constraints

  • 180s nginx read timeout. Imports that take longer than that will appear to fail with a 504 even though the backend usually finishes the work. After a 504, query the database to confirm whether the rows landed before re-running.

  • No cancellation. There is no jobId, so DELETE /api/imports/{jobId} does not apply. Plan file sizes accordingly: a couple of hundred EP rows or a few hundred result rows is the comfortable upper bound on sync.

  • No Team header on EP import at this version. EventParticipantImportXLS routes Schoolyouareattendingcustom_1_id (club) but has no mapping to custom_2_id (team). To populate team on a 2.3.x prod, run a manual custom_list_value upsert + event_participant.custom_2_id UPDATE after the import lands.

  • Bug #471 still live. The matchPerson OTHER-branch creates duplicate Persons when the IDType column is absent. Always ship IDType=NATIONAL as an explicit column on every row of the EP CSV until the fix deploys.

  • No RaceNumber / NumberType header mapping on EP import at this version. The EP importer ignores those columns even when present in the source; event_participant.number_id and tag_id remain NULL after the import. Number/tag linkage is a separate post-import step (see Number & Tag Runbook).

  • Result importer matches on event_category.name, not race.name. The Registration Event column in the bulk result CSV is matched against event_category.name within the event; the importer then finds the race that belongs to that category. A race named differently from its category will cause every CSV row with that race name to come back in unmatchedCategories. For combined-bucket races (e.g. a single "Youth" race spanning multiple age-band categories), create a synthetic event_category of the same name and re-point the race’s FK at it — do not rely on the race’s own name to drive the match.

  • EP import "rollback-only" errors in issues[] are misleading. When Hibernate’s batch flush encounters a constraint violation, every subsequent row in the same batch can be reported with outcome: ERROR and message: "Transaction silently rolled back because it has been marked as rollback-only", even though those rows actually persist via a retry transaction. Do not treat errors_count from the response DTO as the true failure count. Verify against the database: true failures = (rows in source file) − (rows in event_participant for the event after the import). Real failures are typically duplicate RegistrationID rows in the source, not the long tail of "rollback-only" messages.

5. Async Delivery Mode

From release 2.4 all three import endpoints (EP, Memberships, Bulk Results) are asynchronous. The upload returns HTTP 202 Accepted + an ImportJobDTO immediately; the response body you previously got inline is now fetched from a per-entity GET /import/{jobId} endpoint once the job reaches COMPLETED or FAILED.

Common shape:

# 1. Upload — returns immediately with a job identifier
PUT  /api/{entity}/import[-bulk]?...  →  202 Accepted
                                         Location: /api/{entity}/import/{jobId}
                                         { "identifier": "<jobId>", "status": "PROCESSING", ... }

# 2. Poll until the job reaches a terminal status
GET  /api/{entity}/import/{jobId}
       →  409 Conflict                  (still processing)
       →  200 OK + <EntityImportResultDTO>   (COMPLETED or FAILED — payload carries the status)
       →  404 Not Found                 (unknown job id, wrong import type, or expired)

The response DTO returned by the GET is byte-identical to what the prior synchronous endpoint returned inline — the job summary, issues list, counters, number changes, etc., are all in the same shape. Only the delivery mode changed.

Entity Upload endpoint Results endpoint

Event Participants

PUT /api/event-participants/import

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

Memberships

PUT /api/memberships/import

GET /api/memberships/import/{jobId}

Results (bulk CSV)

PUT /api/result-sets/import-bulk

GET /api/result-sets/import/{jobId}

A generic status endpoint GET /api/imports/{jobId} is also available; it returns the framework-level state (status, counters, file-lines, skipMappingPhases) without the domain DTO. Useful during polling when you only need status.

5.1. Cancellation

DELETE /api/imports/{jobId} marks the job as CANCELLED. For result imports (whole-file, monolithic processor), the CANCELLED flag is observed between mapping phases but not mid-processing — the bulk CSV processor runs to completion once started, so a CANCELLED request on a RESULT job that is already in PROCESSING is effectively a no-op until the current file finishes. EP and Membership imports honour cancel between rows.

5.2. Polling example

# Upload
RESP=$(curl -sS -X PUT \
  -H "X-API-KEY: $ADMIN_API_KEY" \
  -F "file=@/path/to/125-ResultExport.csv" \
  "$BASE_URL/api/result-sets/import-bulk?eventId=125&participantIdMode=regid")

JOB_ID=$(echo "$RESP" | jq -r '.identifier')

# Poll every 2 seconds until COMPLETED or FAILED
while :; do
  STATUS=$(curl -sS -H "X-API-KEY: $ADMIN_API_KEY" \
    "$BASE_URL/api/imports/$JOB_ID" | jq -r '.status')
  case "$STATUS" in
    COMPLETED|FAILED) break ;;
    *) sleep 2 ;;
  esac
done

# Fetch the per-entity result DTO
curl -sS -H "X-API-KEY: $ADMIN_API_KEY" \
  "$BASE_URL/api/result-sets/import/$JOB_ID" | jq .

6. Event Participant Import

6.1. When to use it

You have a spreadsheet (from a club secretary, a legacy system export, or a manual roster) and want every row to become an EventParticipant in a named event. Re-running the same file with corrections should update existing records, not create duplicates.

6.2. Preparing the file

The import accepts XLSX (not CSV). Required columns:

  • FirstName (aliases: First Name, FN, Name)

  • LastName (aliases: Last Name, Surname, SN, LN)

Strongly recommended columns for clean upsert on re-run:

  • RegistrationID (aliases: ExternalID, ExternalEPID) — the primary upsert key within the event. Without it, the import cannot recognise existing EPs and will create duplicates.

  • IDNumber, IDType, IDCountry — used for Person matching across imports and events

  • DOB, Email, Cellphone — secondary Person-matching signals

  • CategoryEventCategory.name for the target event

Common extra columns: Gender, Address, Town, PostalCode, Bib / Number, OrderNumber, Schoolyouareattending (as a custom list).

See Event Participant Import Design for the full 32-column list and aliases.

6.3. Running the import

The upload is async (see Async Delivery Mode): this returns 202 Accepted with a job identifier; poll GET /api/event-participants/import/{jobId} for the result DTO.

curl -X PUT \
  -H "X-API-KEY: $ADMIN_API_KEY" \
  -F "file=@/path/to/participants.xlsx" \
  "$BASE_URL/api/event-participants/import?eventId=123&sheetIndex=0&createCustom1=true&createCustom2=true&createCustom3=true"

Parameters:

  • eventId (required) — target event

  • sheetIndex (optional, default 0) — 0-based sheet index in the workbook

  • orgId (optional) — defaults to the event’s organisation; only override for cross-org test data

  • createCustom1/2/3 (default true) — auto-create missing custom-list values (e.g. new school names)

6.4. Importing from an external system (source system & trust)

When the spreadsheet comes from a foreign system (a legacy WPCA export, a partner federation), identify that system so the importer can correlate people correctly and remember it for later result imports.

  • sourceSystemId (optional) — the external source registration system the file came from. The first such import records it onto the event, so result imports default their source-system selector from it (no need to re-pick — US #769). Omit for your own/manually-prepared rosters.

  • trustPKs (default false) — set true only when you trust the foreign system’s id columns enough to resolve by them directly. Left false, those ids are treated as hints and the importer matches on identity (ID number, then name+DOB) with a safety fingerprint check.

  • updatePII (default false) — set true to let the file overwrite existing name/DOB/contact details; otherwise existing PII is only filled where blank.

  • acknowledgeFingerprintWarning (default false) — only set after you have reviewed a flagged person-match mismatch and confirmed it is genuinely the same person.

See the trust matrix for how these combine.

6.5. Reading the response

The DTO below is what comes back from GET /api/event-participants/import/{jobId} once the job has reached COMPLETED or FAILED — not from the upload response.

{
  "totalRows": 185,
  "created": 20,
  "updated": 160,
  "skipped": 0,
  "warnings": 4,
  "errors": 1,
  "issues": [
    { "outcome": "WARNING", "firstName": "...", "lastName": "...",
      "registrationId": "29364", "message": "Ambiguous person match — 2 candidates" },
    { "outcome": "ERROR",   "firstName": "...", "lastName": "...",
      "message": "CustomList 'School' value not found and createCustom1=false" }
  ]
}

Invariant: totalRows == created + updated + skipped + warnings + errors. If it doesn’t match, the import bailed mid-file — investigate logs.

Only non-success rows appear in issues — successful CREATED / UPDATED rows are counted but not listed.

6.6. Success heuristics

  • 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

7. Membership Import

7.1. When to use it

You have a spreadsheet of members to add to a MembershipPeriod — typically from a club secretary, a legacy export, or a renewal drive. The import creates Membership + User (Person) + optional Order + RaceNumber assignments in a single pass.

Unlike EP, the Membership import today has no upsert: re-running the same file will create duplicate memberships rather than updating existing ones. See Membership Outstanding #5.

7.2. Preparing the file

Accepts XLSX. Required columns:

  • FirstName (aliases: First Name, FN, Name)

  • LastName (aliases: Last Name, Surname, SN, LN)

Strongly recommended columns for clean processing:

  • MembershipNumber (aliases: Member#, MemberNo, Membership#) — required today; the import throws SKIPPED if missing

  • IDNumber, IDType, IDCountry — drive Person matching; an existing Person with a different identity number will reject the row as ERROR

  • MembershipTypeID, MembershipCriteriaID — FK ids into the period

Common extras: Gender, DOB, Email, Cellphone, Address, Town, PostalCode, OrderNumber, Status.

See Membership Import Design for the full 30-column list and aliases.

7.3. Running the import

The upload is async (see Async Delivery Mode): this returns 202 Accepted with a job identifier; poll GET /api/memberships/import/{jobId} for the result DTO.

curl -X PUT \
  -H "X-API-KEY: $ADMIN_API_KEY" \
  -F "file=@/path/to/members.xlsx" \
  "$BASE_URL/api/memberships/import?periodId=17&sheetIndex=0"

Parameters:

  • periodId (required) — target MembershipPeriod

  • sheetIndex (optional, default 0) — 0-based sheet index in the workbook

  • orgId (optional) — defaults to the period’s organisation; only override for cross-org test data

7.4. Reading the response

The DTO below is what comes back from GET /api/memberships/import/{jobId} once the job has reached COMPLETED or FAILED — not from the upload response.

{
  "totalRows": 120,
  "created": 118,
  "skipped": 1,
  "errors": 1,
  "issues": [
    { "lineNumber": 47, "outcome": "SKIPPED",
      "message": "Membership number not supplied" },
    { "lineNumber": 93, "outcome": "ERROR",
      "message": "Identity number does not match existing person" }
  ]
}

Invariant: totalRows == created + skipped + errors.

The issues list contains one entry per row that did not result in CREATED. Each issue carries the source lineNumber to help correct the file and re-run — trim the XLSX to only the failed rows before retrying, because the import today has no upsert and will duplicate previously-created memberships.

7.5. Success heuristics

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

  • Partial failure with recoverable rows: a small number of issues — fix the source file and re-run only the failed rows (trim the XLSX) to avoid duplicates

  • Systematic failure: every row produces the same ERROR (e.g. "Identity number does not match") — stop, investigate the file’s identity columns, don’t blindly retry

8. Result Import (Bulk CSV)

8.1. When to use it

The timing system (RaceDay Scoring) has produced a multi-category CSV. You want every category’s ResultSet to reflect the finishing order in the file.

8.2. Preparing the file

The file comes straight from RaceDay Scoring. You should not hand-edit it. The expected shape:

Bib,First Name,Last Name,School,Registration Event,External Reference ID,Place,Total Time,Num. Laps
219,Freddie,Visser,,Category 1,29364,1,00:57:37.12,12
225,Imtiyaaz,Schultz,,Category 1,29349,2,00:57:58.56,12
...
(blank line)
Bib,First Name,...                                                       ← repeated header
103,Someone,Else,,Category 2,29400,1,00:52:11.03,11
...

Concatenated per-category sections (each with their own header and a blank separator) are expected. The importer classifies these as non-data rows automatically.

8.3. Choosing participantIdMode

This is the single most important decision. Getting it wrong can wipe pre-existing results (see Recovering from a Mode Mismatch).

Mode Column interpretation When to use

epid (default)

External Reference ID = EventParticipant.id (new-system PK)

The timing system was told about EPs via the new-system PK (rare). Most clean-slate events start this way.

regid

External Reference ID = EventParticipant.registrationId

The timing system holds legacy-system PKs because EPs were migrated from a legacy system. Use this for all migrated WPCA Road League events (119, 120, 121, and the rest of Series 10).

pid

External Reference ID = EventParticipant.person.id

The timing system holds the Person/User id (e.g. for cross-event standings). Uncommon in current operations.

xpid

External Reference ID = the source system’s person id (a Person-XID), resolved via the event’s source registration system

An external system feeds the timing system directly — the column holds its person id, not ours and not its per-event entry key. Requires a source registration system (see sourceSystemId (source registration system)).

Identify the data source first, then the mode follows. Ask: whose ids are in the External Reference ID column?

  • Our system loaded the timing dataepid (our EP key) or pid (our person key).

  • A legacy/partner system’s entry ids, migrated onto our EPs at participant importregid (the Participant-XID).

  • A partner system’s person ids, because it feeds the timing provider directlyxpid (the Person-XID) — and set the source registration system.

When the event has a preferred timing identifier configured, the admin portal pre-selects the matching mode for you; confirm it still matches this file.

Verify the mode AND the target event before firing. The CSV’s External Reference ID column will always look like integer ids — you cannot tell from the file whether they are EP ids, registration ids, person ids, or ids that belong to a different event entirely. A single bad guess is enough to wipe pre-existing results (see Recovering from a Mode Mismatch).

Spot-checking a single id is not enough. Run a coverage check that confirms the bulk of the file resolves under the chosen mode against the target event. If coverage is below ~95%, stop and investigate before firing.

Coverage check (replaces the older single-id spot-check):

# 1. Extract the unique External Reference IDs from the CSV (column 6).
awk -F, 'NR>1 && $6!="" && $6!="External Reference ID"{print $6}' \
    <result-file>.csv | sort -u > /tmp/xrids.txt
echo "Unique XRIDs in file: $(wc -l < /tmp/xrids.txt)"
-- 2. Load and check coverage against the target event in each candidate mode.
CREATE TEMPORARY TABLE tmp_xrid (rid VARCHAR(20));
LOAD DATA LOCAL INFILE '/tmp/xrids.txt' INTO TABLE tmp_xrid;

SELECT 'epid mode (EP.id)',
       (SELECT COUNT(*) FROM tmp_xrid) AS total,
       COUNT(DISTINCT x.rid) AS resolve_target
FROM tmp_xrid x
JOIN event_participant ep ON ep.id = x.rid AND ep.event_id = <eventId>;

SELECT 'regid mode (EP.registration_id)',
       (SELECT COUNT(*) FROM tmp_xrid) AS total,
       COUNT(DISTINCT x.rid) AS resolve_target
FROM tmp_xrid x
JOIN event_participant ep ON ep.registration_id = x.rid AND ep.event_id = <eventId>;

SELECT 'pid mode (Person.id)',
       (SELECT COUNT(*) FROM tmp_xrid) AS total,
       COUNT(DISTINCT x.rid) AS resolve_target
FROM tmp_xrid x
JOIN event_participant ep ON ep.person_id = x.rid AND ep.event_id = <eventId>;

-- 3. Sanity-check: where ELSE might these ids resolve? Catches wrong-target-event
--    cases where the file is in the right shape but aimed at the wrong event.
SELECT ep.event_id, e.name, COUNT(DISTINCT x.rid) AS hits
FROM tmp_xrid x
JOIN event_participant ep ON ep.registration_id = x.rid   -- swap to ep.id for epid mode
JOIN event e ON e.id = ep.event_id
GROUP BY ep.event_id, e.name
ORDER BY hits DESC;

The mode whose resolve_target matches total (or nearly all of it) is the correct mode for this file. If the highest-coverage event in step 3 is not <eventId>, the file is aimed at the wrong event — do NOT import; either re-target the import or get a corrected file.

8.4. Choosing pointsCalculator

The default is the SA School Cycling calculator. Available alternatives:

Short code When to use

(omit)

SA School Cycling series points (scores down to ~position 67)

wpca-road-league

WPCA Road League scheme: 50/45/40/36/34/32/30/28/26/24/22/20 for top 12; 2 participation points for positions 13+; 0 for non-finishers

A fully-qualified class name is also accepted. See PointsCalculatorFactory in the admin service for the registered list.

8.5. applyNumberChanges

Defaults to false. Leave it off unless you are running a post-race reconciliation where you explicitly want to update participants' RaceNumber assignments to match the bibs they actually used on the day.

When true, only SIMPLE changes are applied automatically (target bib is unassigned stock). SWAP and CONFLICT are always reported but never auto-applied.

8.6. sourceSystemId (source registration system)

Only relevant for participantIdMode=xpid. It names the external system whose person ids the file carries, so the system can resolve each id to the right athlete.

  • In the admin portal, the source-system selector is pre-filled from the event — it was set when participants were imported, and the screen tells you so.

  • If you change it, the portal warns and asks you to confirm, then saves the new value back onto the event. Only change it if you genuinely know the file came from a different source system.

  • Over the API, pass &sourceSystemId=<id>; omit it to use the event’s stored value.

  • For epid / pid / regid you do not need a source system — leave it unset.

8.7. reconciliationScope (how much the file is authoritative for)

Re-importing a category removes finishers who are no longer in the file (an athlete moved to the right category, or withdrawn). reconciliationScope controls how far that removal reaches:

Value Use when

SINGLE_CATEGORY (default)

You are loading one or a few categories' results. Only the categories present in the file are reconciled; every other category’s results are left alone. This is the safe everyday choice.

FULL_EVENT

You are uploading a complete event export and want it to be the single source of truth — a finisher missing from the file should be removed anywhere in the event. Use deliberately.

A wholesale resolution failure can no longer empty a category: the delete pass is skipped for any category where nothing resolved or more than half the rows failed (the post-2026-04-20 guard). Removed results are shown in the import book-end so you can see exactly what was cleared.

8.8. Running the import

The upload is async (see Async Delivery Mode): the PUT below returns 202 Accepted with a job identifier; the full BulkResultImportResponseDTO is fetched from GET /api/result-sets/import/{jobId} once the job reaches COMPLETED.

curl -X PUT \
  -H "X-API-KEY: $ADMIN_API_KEY" \
  -F "file=@/path/to/125-ResultExport.csv" \
  "$BASE_URL/api/result-sets/import-bulk?eventId=125&participantIdMode=regid&pointsCalculator=wpca-road-league"

8.9. Reading the response

The DTO below is what comes back from GET /api/result-sets/import/{jobId} once the job has reached COMPLETED or FAILED.

{
  "eventId": 120,
  "fileLines": 212,
  "summary": {
    "dataRows": 203,
    "imported": 201,
    "skipped": 2,
    "nonDataRows": {
      "blankLines": 4,
      "repeatedHeaders": 4,
      "malformedRows": 0,
      "totalNonData": 8
    }
  },
  "categories": [
    { "categoryName": "Category 1", "raceId": ..., "resultSetId": ...,
      "rowCount": 51, "importedCount": 51, "skippedCount": 0 },
    ...
  ],
  "unmatchedCategories": [],
  "numberChanges": [ ... ]
}

Invariants to check before declaring success:

  • fileLines == 1 (header) + summary.nonDataRows.totalNonData + summary.dataRows

  • summary.dataRows == summary.imported + summary.skipped

  • unmatchedCategories is empty (or contains only values you expect to be missing)

Any non-zero skipped count is worth investigating — those rows were not imported. The detailed issues list for skipped rows is a work-in-progress (ADO-456); until it ships, inspect the application logs for WARN lines around the import timestamp.

8.10. Success heuristics

  • First import of an event: imported == dataRows, skipped = 0, unmatchedCategories = []

  • Re-import after a correction: same as first-import, plus RaceResult.id values in the database are unchanged compared to before the re-import (upsert-by-seq preserves identity)

  • Normal "number change noise": if the event’s NumberType has no RaceNumber stock loaded, every row triggers a CONFLICT number-change report. This is operational data config, not an import error.

9. Recovering from a Mode Mismatch

9.1. Symptom

You ran /api/result-sets/import-bulk and the response shows summary.imported = 0, summary.skipped == summary.dataRows, and the categories[].importedCount is 0 for every category. A follow-up database check shows RaceResult rows for that event have been deleted from the matched ResultSets.

9.2. Why it happens

The reconciliation logic expects the CSV to be the source of truth for each category’s ResultSet. When every row fails to resolve a participant, no seq is "touched", so the end-of-category delete pass removes every pre-existing RaceResult as "absent from the file". This is the correct behaviour for "participant genuinely removed" but the wrong behaviour for any of the failure shapes below. The importer currently cannot distinguish them.

Three failure shapes produce the same 0-imported / data-deleted outcome:

Shape Description Diagnostic

Wrong participantIdMode

The mode you chose doesn’t match how the file’s External Reference ID values were generated (e.g. mode regid but the column actually holds EventParticipant.id).

Coverage in the correct mode is ~100%; coverage in the chosen mode is ~0%.

Wrong target event

The mode is right, but the file’s ids belong to a different event’s EPs (e.g. a timing operator exported last week’s race and re-saved it under this week’s filename, OR carry-over registration ids point at the prior event).

Coverage in the chosen mode against <eventId> is ~0%; coverage against some other event is ~100%.

ResultSet/category mismatch (partial)

Registration Event column values don’t match any EventCategory.name in the target event. Some categories may match while others don’t — the matched ones are subject to the delete sweep.

unmatchedCategories[] is non-empty in the response.

See FR-RI-012: Mode Mismatch Safety for the pending fix.

9.3. Immediate recovery

  1. Identify the correct mode (see [choosing-participantidmode]).

  2. Re-run the import with the correct mode. Because the reconciliation is idempotent and the CSV is authoritative, this restores every row that should have been there.

  3. RaceResult.id values will be different after recovery — the ids you had before the bad run are gone for good. If you have any external system that referenced these ids (unlikely, but possible), it will need to be resynced.

9.4. Prevention

  1. Run the coverage check before firing the import (see [choosing-participantidmode] for the SQL). Coverage below ~95% in the chosen mode against the target event is a hard stop — do not proceed.

  2. Confirm the file is aimed at the right event. Step 3 of the coverage check shows which event the ids actually belong to. If it isn’t <eventId>, do not import.

  3. Test against Dev first for any new event or any event whose timing-system configuration you haven’t seen before.

  4. Check the response summary before walking away — if imported = 0, stop and investigate before re-running. A second blind retry against a damaged event can’t undo the loss.

10. Troubleshooting Catalogue

Symptom Likely cause Resolution

404 Not Found

eventId does not exist in the org you’re authenticated as

Verify the event id via GET /api/events/{id}; check your API key’s org scope

400 Bad Request

Missing file part or corrupted upload

Re-upload; check the file opens in Excel / a text editor; confirm Content-Type: multipart/form-data

504 Gateway Timeout after ~180s

Nginx 180s timeout on synchronous imports; backend usually finishes anyway

Wait 2–5 minutes, then check the database for the expected rows. Avoid retrying blindly or you may double-apply number changes.

summary.imported = 0 (results)

Mode mismatch — see Recovering from a Mode Mismatch

Re-run with correct participantIdMode

unmatchedCategories: ["Some Name"]

Timing system’s Registration Event label does not match any EventCategory.name for the event

Either add the missing EventCategory to the event, or correct the timing export’s label and re-run

Every row produces a NumberChange with CONFLICT

No RaceNumber stock loaded for the event’s NumberType

Operational data config issue — load the bib range into race_number with the correct number_type_id. Not blocking; results still import correctly.

Some rows in issues with WARNING: Ambiguous person match (EP import)

Two or more existing Person records match the row’s identity fields

Manually resolve via the admin UI — merge the duplicate persons, then re-run the import

All laps = NULL after an import

File was imported against an admin-service version older than 2.3.31 (Bug #452)

Re-import against 2.3.31+; upsert-by-seq will fill laps in place without churning RaceResult.id

11. Pre-Flight Checklist

Before running against production:

  1. [ ] File opens cleanly in Excel (EP import) or a text editor (Results import)

  2. [ ] Required columns are present (FirstName / LastName for EP; Registration Event + External Reference ID + Place for Results)

  3. [ ] Target eventId is correct (double-check against GET /api/events/{id})

  4. [ ] For Results: participantIdMode is confirmed via the coverage check (see [choosing-participantidmode]) — chosen mode resolves ≥95% of file XRIDs against <eventId>, and step 3 confirms the highest-coverage event IS the target event

  5. [ ] For Results: pointsCalculator is correct for this series

  6. [ ] Same file has been run successfully against Dev (for any new file shape)

  7. [ ] You have a rollback plan — most commonly "re-run with the correct parameters"; occasionally "restore the pre-import snapshot from the DB backup"