[E08] Import Results

Summary

Result-import bookends of the async import flow. Mirrors E06; reuses C05 for the mapping middle steps. Drives an event organiser through uploading a results CSV/XLSX, resolving any column mappings, and reviewing the per-row reconciliation including unresolved EPs and DNF/DNS/Drop entries.

Actor & Context

Actor: event organiser, results officer, tenant admin. Frequency: per event (initial result import, corrections during the verification window). Precondition: event has finished; EPs exist; user has EVENT_MANAGER or RESULTS_OFFICER permission. Entry point: Import results button on E04 Event Results; from E05's Results unverified pill click.

Main Flow

  1. Upload screen. File picker (CSV/XLSX), result-import-specific options:

    • Identifier mode (participantIdMode) — EPID / PERSON_ID / REGISTRATION_ID / EXTERNAL_PERSON_UID. Defaults from Event.preferredTimingIdentifier. The four modes drive how the inbound ExternalReferenceID column is interpreted to match the EP record (vocabulary: Person-XID / Participant-XID):

      • EPID → match EventParticipant.id (ours)

      • PERSON_ID → match (Event.id, Person.id) composite (ours)

      • REGISTRATION_ID → match EventParticipant.registrationId — the Participant-XID (the source system’s entry key, used when EPs were imported from an external registration system)

      • EXTERNAL_PERSON_UID → match the Person-XID via PersonExternalReference → person → that event’s EP, scoped by the selected source registration system (US #769; for files where an external system feeds the timing provider directly)

    • Source registration system (sourceSystemId, US #769) — selector listing non-self registration systems. Defaulted from Event.sourceRegistrationSystem (set when participants were imported) with a hint saying so. Required when the mode is EXTERNAL_PERSON_UID; optional otherwise. Changing it from the event’s stored value raises a warn-and-confirm dialog ("This event’s source registration system is 'X', set when participants were imported. You are changing it to 'Y'. This updates the event…"); on confirm the new value is persisted back onto the event.

    • Override-confirmation — when the operator picks a mode that differs from Event.preferredTimingIdentifier, an inline warning appears: "You are overriding the event’s default timing identifier ('PERSON_ID'). Re-importing with the wrong mode can wipe existing results — confirm only if you are sure the file uses the alternate identifier." A confirmation checkbox ("I confirm this file uses the selected identifier") gates the Submit button. Legitimate override case: cross-system migration imports.

    • Reconciliation scope (reconciliationScope, US #771, default SINGLE_CATEGORY) — how much of the event the file is authoritative for, governing the delete pass. SINGLE_CATEGORY reconciles only the categories present in the file (safe everyday default); FULL_EVENT treats the file as the complete event export, removing finishers absent from it anywhere in the event.

    • Apply number changes (applyNumberChanges, default off) — when on, detected number swaps/reassignments are applied to EventParticipant.number after the result reconciliation pass. Always reported regardless of toggle; toggle controls whether the change is applied.

    • Points calculator (pointsCalculator) — short code (e.g. wpca-road-league, sa-school-cycling) selecting the points strategy. Defaults to the event’s pre-configured calculator; operator can override per-import.

  2. User clicks Upload — PUT /api/result-sets/import (multipart) returns 202 Accepted with Location: /api/imports/{uuid}. Bookend writes localStorage.setItem('importCaller:' + uuid, 'e08:' + eventId) (per-uuid key — supports concurrent imports).

  3. Redirect to C05 for column mapping + progress. Cells stage is short-circuited (no FK mappings — categories resolved against Race.category.name); fingerprint stage is bypassed (no source-system gating).

  4. C05 redirects back on completion to the summary screen.

  5. Summary screen. Per-row reconciliation:

    • Reconciliation counts (created / updated / deleted / unchanged result counts).

    • Per-category panel showing imported vs. existing rows.

    • Unresolved EPs surfaced as actionable issues (US #456) with ExternalReferenceID value + suggested next-action.

    • DNF / DNS / Drop entries reported separately (US #457) with synthetic trailing position applied.

    • Non-data-line counts (US #455) — blank lines, repeated headers, malformed rows, unmatched categories.

    • Number-change panel — list of detected number changes (SIMPLE / SWAP / CONFLICT) with applied / not-applied state.

    • Identifier-mode mismatch warning banner — surfaces the two distinct cases:

      • Soft warning — operator’s chosen mode differs from Event.preferredTimingIdentifier but resolution succeeded for most rows. Banner: "You imported with mode 'X'; the event default is 'Y'." Informational; non-blocking.

      • Delete-guard warning (as shipped, US #771) — for any category where zero rows resolved, or the resolution failure rate exceeded the threshold (import.result.reconciliation-guard-threshold, default 0.5), the reconciliation delete pass was skipped for that category: resolvable rows were still imported, but no existing results were deleted. Banner: "Deletion skipped for category 'X' — only NN% of rows resolved (likely wrong identifier mode or source system). Existing results were kept." with a "Try again with different mode" link back to upload (file selection retained) and a per-category failure-rate panel. This is a non-destructive guard, not a whole-import abort — the rows that did resolve are imported, and any deletions that occur are listed in the deleted panel above.

    • Link to E04 for follow-up.

Alternative Flows

  • AF-1: Re-import during correction window — existing results updated in place via upsert-by-seq; summary distinguishes new vs corrected.

  • AF-2: Re-import after correction window closed — soft warning; user must confirm before submit.

  • AF-3: Cancel / fatal error — same patterns as E06.

  • AF-4: Wrong-mode catastrophic abort — backend’s resolution-failure-rate guard returns 422; summary surfaces the hard-error banner; no data changed; operator picks a different mode and retries.

Acceptance Criteria

  • Use-case page authored.

  • Status design-todo → handoff-ready after Claude Design pass.

  • :design-url: populated.

  • Cross-references C05 + E04 + E05.

  • DNF / DNS / Drop entries surfaced separately per US #457.

  • Unresolved EPs surfaced as actionable issues per US #456.

  • Override-confirmation friction shown when operator picks a mode different from Event.preferredTimingIdentifier.

  • Hard-error banner with retry-with-different-mode affordance when backend returns 422 from the resolution-failure guard.

API Surface

Call Purpose

PUT /api/result-sets/import (multipart, returns 202 Accepted)

Create import job from uploaded file + result-import options (eventId, participantIdMode, sourceSystemId, reconciliationScope, applyNumberChanges, pointsCalculator, sheetIndex). Returns Location: /api/imports/{uuid} for C05 polling. Replaces the existing synchronous PUT /api/result-sets/import-bulk once the async path lands.

GET /api/imports/{uuid} (C05 polling)

Generic import-job status + progress. Same wire shape as E06.

GET /api/events/{eventId}

Reads preferredTimingIdentifier and pointsCalculator to seed upload-screen defaults. The identifier-mode value is required for the override-confirmation friction logic.

GET /api/result-sets/imports?eventId=…&recent=10

Drives the upload-screen "Recent imports" rail. Filtered server-side to ImportType=RESULT for the given event.

GET /api/imports/{uuid}/result-summary (terminal — single call on mount, never polled)

Domain-typed summary DTO for the summary screen. Carries reconciliation counts, per-category breakdown, number changes (SIMPLE/SWAP/CONFLICT with applied state), unmatched categories, non-data-row buckets, unresolved EPs, non-finishers, plus the failureReason field when terminal state is FAILED.

(delegates to C05 for column / progress)

Shared mapping flow.

Out of Scope

  • The mapping middle steps — C05.

  • EP-import bookends — E06.

  • Mass result correction tooling — separate Feature.

  • RaceResult.externalReferenceId audit column — deferred follow-up (event-database release blocker).

  • Category change detection apply (multi-pass Pass 3, applyCategoryChanges) — deferred follow-up; out of scope for first E08 ship.

Design Anchors

Design Decisions

  • Wire E08 through the async-import framework (2026-05-08). Match E06 and the bookend contract rather than fronting the existing synchronous import-bulk endpoint. Backend wires RaceResultRowProcessor as a whole-file processor into ImportRowProcessingService (the existing processBulkCsv cross-row reconciliation logic moves under the framework). Upload posts to PUT /api/result-sets/import (202+poll); summary reads GET /api/result-sets/import/{jobId} for the domain DTO. Cells stage skipped (no FK mappings); fingerprint stage skipped (no source-system gating). Reuse of C05 columns stage handles timing-system header drift (e.g. Num. Laps vs NumLaps).

  • Three identifier modes for participant resolution (2026-05-04). The inbound ExternalReferenceID column is interpreted as one of:

    • EPID — our internal EventParticipant.id. Used when our system is authoritative for the event lifecycle.

    • PERSON_ID — our internal Person.id. Default for new events; safe across multiple events for the same person.

    • REGISTRATION_IDEventParticipant.registrationId (the source system’s EP UID). Used when EPs were imported from an external registration system and we want timing to round-trip their identifier back.

      The mode default per Event lives in Event.preferredTimingIdentifier (set during EP creation, lockable via the timing-export UI gate). Result import reads this default but lets the operator override per-import — useful when re-importing legacy result files exported with a different mode than the Event currently has set.

  • Override-confirmation friction on the upload screen (2026-05-08). When the operator picks a mode that diverges from Event.preferredTimingIdentifier, the upload screen shows an inline warning + requires a confirmation checkbox before Submit enables. Two layers of defence with the backend’s resolution-failure-rate guard. The legitimate override case (cross-system migration) takes one extra click; the catastrophic-mistake case is harder to commit. Backend guard remains authoritative — friction is belt-and-braces.

  • Backend resolution-failure-rate guard against catastrophic delete (2026-05-08). When the row-resolution failure rate exceeds a threshold (e.g. >50% of rows in a category, or every row), the reconciliation pass refuses to delete existing data and returns HTTP 422 with a "likely wrong participantIdMode`" diagnostic. Surfaced on the summary screen as a hard-error banner with a "Try again with different mode" link back to upload (file selection retained). Closes the silent-data-loss vector observed on event 119 in production (2026-04-20: a single `epid vs regid mistake wiped 182 RaceResult records).

  • Fourth identifier mode + source registration system, hybrid default (2026-06-07, US #769, shipped). Added EXTERNAL_PERSON_UID for files carrying the source system’s person id (resolved via PersonExternalReference → person → EP). The source system is a hybrid: Event.sourceRegistrationSystem is set-if-absent at participant import and defaults the upload selector; the operator may override per-import, and a confirmed change is persisted back onto the event. Rationale: re-prompting the source on every category file is tedious and error-prone; remembering it on the event with a per-import override + confirm keeps it both convenient and correctable. The selector is required only for EXTERNAL_PERSON_UID. See Participant Identity.

  • Reconciliation scope + as-shipped delete guard (2026-06-07, US #771, shipped — supersedes the 2026-05-08 HTTP-422 design above). The catastrophic-delete guard shipped as a non-destructive per-category skip, not a whole-import 422 abort: a category whose resolution failure rate exceeds import.result.reconciliation-guard-threshold (default 0.5), or where nothing resolved, has its delete pass skipped while resolvable rows still import. Paired with an explicit reconciliationScope (SINGLE_CATEGORY default vs FULL_EVENT) so single-category uploads cannot reach beyond their categories. Deletions that do occur are surfaced in the summary’s deleted panel. Reframed because the 422-abort would have blocked legitimate partial imports; the per-category skip is both safer and less disruptive.

  • Bookend pattern mirrors E06 (2026-05-04). Same upload + summary structure as E06; same C05 caller-key contract pattern; same async-import state machine (minus fingerprint). Result-import-specific options (identifier mode, apply-number-changes, points calculator) live entirely on the upload screen + summary; the mapping middle steps remain content-type-agnostic.

  • Per-uuid caller-key with eventId suffix (2026-05-08, refined from design pass). E08 writes localStorage.setItem('importCaller:' + uuid, 'e08:' + eventId) — per-uuid key (supports concurrent imports) with the :<eventId> suffix carrying the event for the back-link target. C05 host reads + removes the key on terminal state and routes to /events/<eventId>/results/imports/<uuid>. Differs from E06’s bare 'e06' value: E06 is portfolio-level, E08 is event-scoped.

  • DNF and repeated-header toggles dropped (2026-05-08). The earlier draft proposed upload-screen toggles for DNF status handling (US #457) and repeated-header tolerance (Bug #453). Both behaviours have shipped to admin-service as always-on (PRs #146 + #147 merged). No operator decision is needed; the toggles are noise.

Notes

Backend result-import improvements: PR #146 (merged) + PR #147 (merged). USs #455–#458 detail the row-level reporting nuances the summary screen must surface. The 2026-05-08 design wires the existing whole-file processor (ResultImportXLS.processBulkCsv) into the async framework and adds the catastrophic-delete guard. F12 (Event.preferredTimingIdentifier column + GET exposure) is a precondition for the upload-screen default and ships as its own US in this workstream.