Skip to content

Replace the parish-centroid coordinate fallback with a map pin the applicant places themselves #2575

Description

@sajclarke

Why the current fallback is a fudge

#2571 made a catchment-routed submission always carry a coordinate: if the applicant never picked a geocode suggestion, the server fills the coordinate from the parish they selected. That fixed the real bug — the CMS was receiving applications with no location at all — but the filled value is an invention, and the shape of the invention has already had to change once.

The points started as the serving polyclinic's own location, which put the CMS map pin on the clinic building. 77bbb0ca moved them to parish centres, which reads honestly ("somewhere in this parish, location not captured"). But a parish centre still has to sit inside the catchment the parish routes to, and catchment boundaries do not follow parish lines — so two of the eleven had to be nudged off their true centroid, and they landed effectively on the boundary. Measured against polyclinic-catchments.geojson:

Parish Margin to its catchment boundary
st-peter / st-lucy / st-andrew 2.5–3.1 km
st-philip / st-thomas / st-joseph / st-john 1.4–2.8 km
st-james 823 m
st-michael 58 m
st-george 7 m
christ-church 2 m

Containment is unambiguous today (exactly one catchment contains each point, so pointHit's first-match-wins is never deciding by feature order), and parish-routing-point.spec.ts asserts the per-parish invariant, so a regression fails in CI rather than misrouting silently. But any correction, re-export or simplification of the catchment GeoJSON moves boundaries by far more than 7 m, and Christ Church and St George would flip.

Re-deriving those three for maximum margin would buy time. It would not change the fact that we are guessing at a location the applicant could simply have told us.

Proposed resolution

Stop guessing. Make the coordinate something the applicant supplies, always, by one of two routes:

  1. Pick a geocode suggestion — unchanged, and still the fast path for most applicants.
  2. Place the pin themselves — when none of the suggestions is right, the address field offers to open a map in a modal. The applicant drags a pin to the premises and confirms. The modal also offers use my current location (Geolocation API) as a shortcut for someone standing at the premises.

With that in place:

  • Free-text addresses are no longer accepted for a catchment-routed form. Today typing an address that never geocodes is a silent dead end for routing; it would become a field the applicant cannot leave until they have either picked a suggestion or placed a pin.
  • The parish fallback goes away entirely. PARISH_ROUTING_POINTS, fillParishRoutingCoordinate and its spec all delete. So does parishHit / the routing use of PARISH_DEFAULTS in CatchmentRoutingService, and eventually catchmentRouting.parishField in the recipe schema (a migration across the 12 routed recipes — worth phasing separately from the UI work).
  • The coordinate guard in SubmissionsService.submit stops being a backstop and becomes the real contract. fix(forms): surface server validation errors on the fields they name #2573 already surfaces its 422 on the field it names, so the failure mode is actionable.
  • Several conditions in catchment-coordinate-to-cams.spec.ts collapse: "typed it, never picked", "left a blank coordinate", "left a malformed coordinate" all become unreachable rather than fallback cases.

Things to settle before building

These are the questions that would change the design, not a checklist to work through afterwards.

Accessibility is the hard one. Dragging a pin on a map is not operable by keyboard or screen reader, and Standard 5 / WCAG 2.1 AA apply. "Free text is no longer allowed" and "the map is the only alternative to a suggestion" cannot both hold without excluding people. Options worth testing: keyboard-operable pin nudging with an announced address readback; a "none of these is right" path that captures a written description plus the parish and flags the case for an Environmental Health Officer to resolve; or accepting a coarser coordinate from a keyboard-only path and marking its provenance. Needs a decision with content and research, not a technical fix.

Provenance, not just format. isRoutingCoordinate is a format check — nothing downstream can tell a geocoded coordinate from a dropped pin from an invented centroid. If a case reviewer is going to act on the pin, the payload should say where it came from (geocoded / placed / device). That is the thing that actually fixes "a pin that looks precise but isn't", and it is worth doing whether or not the map ships.

Geolocation can reproduce the exact bug we are fixing. A citizen or an agent filling the form at home, or at an office, would drop a confidently precise pin in the wrong place. Device accuracy should be read and shown, the value should be labelled as device-derived, and the applicant should confirm it on the map rather than have it accepted silently.

Map tiles are new infrastructure. The monorepo has no map library today, and the only mapping dependency is the Nominatim proxy at /geocode. Tiles mean a provider (self-hosted, or a service with its CSP origin allowed), a bundle-size decision, and a story for what the field does when tiles fail to load — which must not be "the applicant cannot submit".

Privacy. For the forms where the routed address is someone's home (the hairdresser licence routes on personal-details.address-coordinates), we would be capturing a precise home coordinate where we previously captured a parish. Worth a look from the privacy notice angle.

Existing data. Submissions already carry parish-centroid coordinates. Anything that later reads coordinates as precise needs to know those are not.

Suggested phasing

  1. Provenance on the coordinate (small, independently useful, unblocks the rest).
  2. Accessibility decision on the non-map path — before any UI is built.
  3. Map modal: pin placement + geolocation, with the suggestion path unchanged.
  4. Turn off free text for routed forms; delete the parish fallback and its tests.
  5. Recipe migration to drop catchmentRouting.parishField.

Steps 4 and 5 are the ones that remove code; nothing before them changes routing behaviour, so each can ship on its own.

Context: #2571 (the fallback this replaces), #2573 (surfaces the 422 the hard requirement depends on), #2574 (the payment-gated path, unrelated but touches the same resolution).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:frontendFrontend apps + shared frontend packagesenhancementNew feature or requestseverity:importantSignificant impact, schedule soonsubsystem:apiapps/api (NestJS)subsystem:formsapps/forms (citizen-facing UI)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions