Public Participant List

1. Overview

As people register for an event they want to confirm their entry landed, and to see who else is entered. Organisers want the same view, broken down by category and by school or club.

Before this screen the need was met by hand: run a SQL query against event_participant, paste the result into a spreadsheet, add pivot tables for counts per category and counts per school, and email it to the organisers. The result was manual, stale the moment it was sent, and visible only to the people on the mailing list.

The Public Participant List replaces that workflow with a screen at /event/participants?eventId={eventId} that reads live data, filters in the browser, and computes the same summaries the pivot tables produced.

Key characteristics:

  • Fully public — no login, no guest session, no account created for the visitor

  • Reduced fields — names, category and custom-list values only; no date of birth, gender, age, payment status, email or identity number

  • Generic custom lists — auto-detects whichever of the event’s three configurable custom lists are in use

  • Built-in summaries — counts per category and per custom list, replacing the manual pivot

  • Client-side filtering — custom-list value, category, and free-text name search

1.1. The query being replaced

The original was specific to one event and assumed a single custom list (School) in event.custom_list_1_id:

SELECT ep.last_name, ep.first_name, ep.date_of_birth, ep.gender, ep.age,
       ec.name AS Category, sc.name AS School,
       IF(pay.is_paid = 1, 'Paid', '') AS OrderStatus
FROM event_participant ep
  LEFT JOIN (
      SELECT oli.event_participant_id,
             MAX(so.status = 'P')  AS is_paid,
             MAX(so.status <> 'C') AS has_non_cancelled
      FROM order_line_item oli
      JOIN sales_order so ON so.id = oli.order_id
      GROUP BY oli.event_participant_id
  ) pay ON pay.event_participant_id = ep.id
  JOIN event_category ec ON ec.id = ep.category_id
  LEFT JOIN custom_list_value sc ON sc.id = ep.custom_1_id
WHERE ep.event_id = 143
  AND ep.active = TRUE
  AND (pay.event_participant_id IS NULL OR pay.has_non_cancelled = 1)
ORDER BY sc.name, ep.last_name, ep.first_name;

The screen keeps the selection semantics of this query exactly (see Participant Selection Rules) but drops the personal columns and generalises the single School join to all three custom-list slots.

2. Access Model

2.1. Public and unauthenticated

The screen and its backing endpoint are reachable with no credentials at all.

The portal’s other screens sit behind authGuard, which bounces anonymous visitors through /login into a guest session — auto-creating a wp_users row and issuing a JWT. That mechanism is deliberately not used here. This link is intended to be published widely (event website, WhatsApp groups, entry confirmation emails), and creating a user account for every person who opens it would pollute the user table with records that represent nobody.

The route is therefore declared in the public block of app-routing.module.ts, alongside /login and the error routes, rather than in the authGuard-protected EntityRoutingModule.

2.2. What is exposed

Exposed Withheld
  • Last name

  • First name

  • Team indicator

  • Event category name

  • Custom-list value names (e.g. School)

  • Date of birth

  • Gender

  • Age

  • Payment / order status

  • Email address

  • Identity number

  • Person id, participant id beyond an opaque row key

The withheld columns are not filtered out at the DTO layer — they are never read. The backing query is a JPQL constructor projection naming only the exposed columns, so the generated SQL does not touch date_of_birth, gender or identity_number at all. The privacy guarantee is a property of the query, not of developer discipline, and is asserted directly in PublicEventParticipantResourceIT.

Payment status is omitted deliberately. Entries appear whether or not payment has cleared, so the screen carries a standing note to that effect — without it, an unpaid entrant reads their own absence from a "paid" column as a lost entry.

2.3. No organisation scoping, deliberately

The endpoint applies no organisation filter. It resolves the event by id, and the event’s organiser is whatever the schema says it is; nothing in the request asserts an organisation.

An earlier iteration of this design had the gateway forward the host-resolved registration_system_id as a header, and the endpoint reject events belonging to any other organisation. That was dropped for two reasons.

First, it contradicts ADR-0009, which holds that organisation scope is always derived by the server from its own grants and schema: "A request carrying no principal-derived organisation identity has no organisation identity, and no client field supplies one." This request has no principal by design, so from admin-service’s side of the boundary a forwarded header is precisely the client-supplied organisation identity that rule forbids — the same shape as the 2026-07-29 incident that prompted it.

Second, it did not work. A caller addressing admin-service directly could set any value, so the header stopped casual eventId editing and nothing else. It looked like a control without being one, which is worse than having none.

2.4. Consequences accepted

Event carries no published or visible flag today. Until one exists, any event in the database is publicly readable through this endpoint — every organisation, past events, and events not yet open for entry — subject only to the reduced field set above.

This is accepted deliberately and temporarily. The successor is a schema-owned visibility flag on Event, which the endpoint will gate on; that is tracked as a separate feature under Epic #34 and is the ADR-0009-consistent answer, because the scope then comes from the server’s own schema rather than from anything in the request. Until it lands, the reduced field set is the only control, and the decision to publish rests on the field set being genuinely non-sensitive rather than on access restriction.

The rationale and alternatives are recorded in ADR-0010.

3. Data Contract

3.1. Endpoint

Item Value

Method and path

GET /api/public/events/{eventId}/participants

Frontend path

services/admin-service/api/public/events/{eventId}/participants

Authentication

None. permitAll in admin-service; exempt from the portal’s JWT relay.

Cache-Control

no-store

Responses:

Status Meaning

200

Roster returned, possibly truncated

404

Event does not exist

3.2. Response shape

{
  "eventId": 143,
  "eventName": "WCSC Race 4",
  "customLists": [ { "slot": 1, "label": "School" } ],
  "participants": [
    {
      "id": 98211,
      "lastName": "Naudé",
      "firstName": "Anelle",
      "team": false,
      "categoryId": 2396, "categoryName": "Sub-Junior Girls",
      "custom1Id": 512, "custom1Name": "Worcester Gimnasium",
      "custom2Id": null, "custom2Name": null,
      "custom3Id": null, "custom3Name": null
    }
  ],
  "truncated": false
}

The customN fields are flat rather than an aligned array. This lets the query build the final DTO in a single constructor expression, and gives the table component a real property name per dynamic column, so column sorting works without custom comparators.

The example shows every field, which is what the dev profile returns. Outside dev, admin-service serialises with default-property-inclusion: non-empty, which drops null values and empty collections from the wire. So on stage and production:

  • an event that uses no custom list returns no customLists key, not "customLists": [];

  • an event with no qualifying participants returns no participants key;

  • the unused customN pairs are absent rather than null.

The client must read an absent collection as empty. Nothing else about the shape changes: truncated is a primitive and is always present. Test fixtures for the no-custom-list case should omit the key, because a fixture carrying [] cannot catch a missing guard.

3.3. Generic custom lists

An event may configure up to three custom lists (custom_list_1_id … custom_list_3_id, each with an optional display name in custom_list_N_name). The reference query hard-coded slot 1 as School; the endpoint reports whichever slots are in use and the screen renders a column and a summary per reported slot.

A slot is reported when either:

  • the event has a customListN configured, or

  • any returned participant carries a non-null customNId.

The second clause is defensive. Imported participants can retain a value in a slot whose list was later cleared from the event; hiding populated data would be worse than showing an extra column.

The column label is the first non-blank of custom_list_N_name, the CustomList entity’s own name, then List N.

3.4. Values from outside the configured list

event_participant.custom_N_id is a plain foreign key to custom_list_value. Nothing in the schema constrains it to values belonging to the CustomList configured in the event’s matching slot, and in practice it sometimes does not: an event may configure custom_list_1_id = 5 while an individual participant is set by hand to a value from list 6.

Such values are displayed, filterable and counted, exactly like any other. The endpoint reads the value through the participant’s own FK and never checks which list it belongs to.

This is a consequence of one rule, which is load-bearing and must not be "improved" away:

Column values, filter options and summary buckets are all derived from the participants actually returned, never from the configured CustomList definition.

Fetching the canonical list to populate the dropdown would look tidier and would break this case in a way that is hard to spot: the off-list value would still appear in the table and in the summary, but not among the filter options — a visible row that cannot be filtered to, and a summary bucket matching no dropdown entry.

The one cosmetic limit is the column heading. A value borrowed from list 6 is displayed under the heading configured for slot 1, because the heading belongs to the column rather than to the row. Where the two lists are semantically compatible — a school missing from the primary list and picked up from a secondary one — this reads correctly. Where they are not, the heading will be wrong for that row, and the fix belongs in the data rather than in the screen.

4. Participant Selection Rules

A participant appears when all of the following hold.

  1. event_id matches the requested event.

  2. active is true. A null active does not qualify.

  3. The participant either has no order line items at all, or has at least one order line item whose order is not cancelled.

Rule 3 reproduces the reference query’s pay.event_participant_id IS NULL OR pay.has_non_cancelled = 1. Expressed as sets, with A = "has any order line" and B = "has a non-cancelled one", the condition is ¬A ∨ B; since B ⇒ A this is exactly "not (has orders, and all of them are cancelled)".

The condition needs two correlated EXISTS subqueries. A single NOT EXISTS (… status = 'C') is not equivalent — it would drop a participant holding one cancelled order and one paid order, which is a routine outcome when an entry is corrected and re-paid.

4.1. Deliberate divergence: null order status

The reference query drops a participant whose only order has a null status, because MAX(so.status <> 'C') yields null and fails the = 1 test. The endpoint treats a null status as not cancelled and keeps the participant.

Losing a genuine entry to a data-quality null is worse than showing one extra row on a public list. This is the only intended difference between the screen’s output and the reference query, and it is the first thing to check when reconciling counts between them.

4.2. Ordering and size

The endpoint returns rows ordered by last name, first name, id. The reference query’s ORDER BY sc.name, … grouping is reproduced in the browser by defaulting the table’s sort to the first configured custom list followed by the name columns — the server ordering is deterministic but not authoritative, because the user can re-sort any column.

The whole roster is returned in one response. The summaries require the complete set regardless, and deriving both the table rows and the pivot counts from one in-memory array means they cannot disagree. A typical schools event is a few hundred to about two thousand rows, which is roughly 30–40 KB gzipped.

A defensive cap of 5 000 rows applies. Beyond it the response is trimmed and truncated is set; the screen shows a warning and hides the summaries, because counts over a truncated set would mislead. The cap is not expected to fire.

5. Screen Design

Route: /event/participants?eventId={eventId}. The eventId parameter is required; there is no picker and no auto-selection.

5.1. Layout

A filter toolbar sits above a two-tab view, so that it visibly governs both tabs:

  • Participants — the roster table

  • Summary — counts per category and per configured custom list

Placing the filters outside the tabs is what makes it obvious that the summaries reflect the current filter rather than the whole event.

5.2. Columns

Surname, Name, Category, then one column per reported custom list. A missing first name renders as an em dash, as does a null category name — event_category.name is nullable.

The roster projects team_indicator and the screen will badge such a row, but no team entry can exist today. EventParticipant.person is @NotNull while setTeamIndicator(true) clears person, firstName and dateOfBirth, so the two rules are mutually exclusive and a team row fails bean validation on save. Production agrees: all 28 494 event_participant rows carry team_indicator = false, none null.

The field is carried anyway because reading a column that already exists costs nothing and keeps the roster correct if team rows ever arrive by another route, such as a direct import. Nothing in the screen should be built on the assumption that they will.

5.3. Filters

All filters combine with AND:

Filter Behaviour

Custom list

One dropdown per configured list, clearable, with a type-ahead filter for long lists such as schools

Category

Single clearable dropdown

Name search

Free text, matching last name, first name, first last and last, first

Three behaviours are load-bearing and easy to get wrong:

  • Dropdown options derive from the unfiltered set, never from the currently filtered rows. Deriving them from filtered rows empties the category dropdown as soon as a school is chosen, leaving the user unable to widen the selection again.

  • Search normalises case and diacritics. Surnames such as Müller, Naudé and Röscher are routine in this user base, and a visitor typing naude must match Naudé.

  • A "(not specified)" option needs a distinct sentinel value, because a cleared dropdown also emits null.

Filter state is mirrored into the URL query string so organisers can share a pre-filtered view.

5.4. Summaries

One compact table per grouping — by category, and one per configured custom list — each sorted by count descending, with a (not specified) bucket for null values and a total that equals the number of filtered rows.

Every total is an entry count, matching the Entries heading the tables use. Each participant row contributes exactly one entry to exactly one bucket per grouping, so the buckets partition the filtered set and always sum to the number of rows on show.

In practice an entry count and a headcount coincide, because event_participant carries a unique constraint on (event_id, person_id) and every row must have a person — person is @NotNull. The screen does not rely on that, though: it counts rows, and would keep summing correctly if a row without a person ever appeared.

5.5. States

Condition Behaviour

No eventId supplied

Warning message; no request is issued

Loading

Spinner

404

"That event could not be found, or it isn’t available on this site."

Other failure

Generic message with a retry action

No participants

Empty-table message

truncated

Warning banner; Summary tab hidden

The screen carries a standing note that entries are shown regardless of payment state, and offers a CSV export of the filtered, sorted view together with a print stylesheet for registration-desk use.

6. Request Flow

public-participant-request-flow

AdminServiceJwtRelayFilter normally fails closed with a 401 on any proxied call that carries no session, so that the frontend can tell an expired session from an anonymous caller. The public prefix is exempted from that check, using the same mechanism already applied to the proxied management/ paths.

The gateway route adds X-API-KEY to every proxied call, including these. That is harmless — the endpoint is permitAll and does not depend on the key. It is worth knowing about, though, because that authority short-circuits the shared security specifications: any future attempt to filter this endpoint’s results through them would silently return everything.

7. Deployment

The screen is served from any tenant host. Adding a host follows the standard tenant provisioning procedure: create the tenant row first, then add the hostname to the ingress, then point DNS at the cluster.

Creating the row before the ingress entry matters — since portal 2.4.1 an unresolved hostname fails loud with a 404 rather than falling back to a default tenant. The roster endpoint itself does not consult the tenant, but the rest of the SPA does, and a host with no tenant row is broken for every other purpose.

Prefer a hostname on a domain we control. A host on a customer-controlled domain cannot solve its ACME HTTP-01 challenge until DNS is delegated, and because all hosts on the ingress share one certificate, a single unreachable host blocks issuance for every other host on it.

Deploy admin-service before the portal, so the portal never calls an endpoint that does not yet exist.