Skip to Content

← All archived runs

Run: regions-and-countries

run.md

Run: regions-and-countries

  • branch: claude/regions-and-countries
  • pr: #844

02_define/output/spec.md

Spec: Regions and countries — two location levels, and only two

  • slug: regions-and-countries
  • personas: Admin
  • touches: packages/services/src/shared/countries.ts (new), packages/services/src/db/models/{location,user,lead}.ts, packages/services/src/db/services/location, packages/services/src/utils/validators.ts, packages/services/src/db/migrations, apps/web/app/(app)/admin/settings/locations, apps/web/app/(app)/admin/territories, apps/web/components/admin/territories-list, apps/web/lib/admin-location-schema.ts, apps/web/components/users/user-profile, apps/web/components/service-leads, apps/web/app/(app)/service-leads/import
  • complexity: standard

Problem

Location is recorded at a territory and state level of detail the business does not use, so a regional total has to be assembled by hand and cannot be trusted to add up. Paul's rule is blunt: two geographic levels only, used consistently for users, customers, service leads, dashboards and reporting (FR-2, FR-10). This advances Q2-2026 Objective 1 (Establish Product-Market Fit with Vendor Partners): regional reporting that adds up is what the Refine-the-bridge initiative's partner conversations lean on.

Proposed change

Region and Country become the only two location levels anywhere in the platform.

  • The standard country list (D-8, Jamie 2026-08-17): ISO 3166-1 English short names as a static list in packages/services (/shared), with no external dependency. Countries are chosen from it, never typed free-hand (BR-15).
  • The location collection becomes region → countries. territories[] is replaced by countries[], each validated against the standard list. Within an organisation a country belongs to at most one region (BR-16) — create/update refuses a country already grouped elsewhere, so regional totals add up without double counting.
  • Renaming a region carries through immediately: the rename cascades to the denormalised location.region strings on the tenant's users and leads in the same operation.
  • A region still holding countries or people cannot be deleted — the delete is refused with the reason; empty it first.
  • Both people and service leads carry region and country; when both are set, the country must belong to the region (BR-17). The shared validateRegionCountry replaces validateRegionTerritory; the user-profile inputs become pickers (region from the organisation's regions, country from the standard list — narrowing to the region's countries when one is chosen, and clearing a country that no longer belongs). A country not yet grouped into any region is a legitimate state and can still be recorded (it simply has no region).
  • Territory and state are retired everywhere they appear, this round: the user and lead location sub-documents drop territory/state (a migration unsets stored values), the admin "Locations" tab manages regions and countries instead of territories, and the remaining territory reads (lead header, user profile, lead import, proposal summary, the expert-matching prompt line) fall back to country.
  • Geography filters data. Geography never grants access — nothing in this feature touches permissions.

Acceptance criteria

  • Location everywhere is region and country only — no screen offers or displays territory or state.
  • Countries are chosen from the standard worldwide list, with no free typing.
  • An administrator can create a region and group countries into it.
  • A region still holding countries or people cannot be deleted.
  • A country's region cannot conflict with the region set on the same record.
  • A country not yet grouped into any region can still be recorded on a person or lead.
  • Renaming a region carries the new name through everywhere immediately.

Out of scope

  • The Region and Country dashboard filters themselves — that is team-region-country-filters.
  • Any access decision based on geography — geography never grants access.
  • Retroactive re-attribution when a person changes region or country.
  • Redesign of expert matching — the prompt line that read territory now reads country; the matching behaviour is otherwise untouched (whole-scope out-of-scope).
  • Recovering retired territory/state values — the migration removes them; git and backups are the record.

Open questions

  • none

03_build/output/notes.md

Build notes: regions-and-countries

  • commits: feat: regions-and-countries — two location levels, and only two

What changed

  • packages/services/src/shared/countries.ts (new): ISO 3166-1 English short names as a static list (D-8, no external dependency) with canonicalCountry / isKnownCountry; exported via /shared.
  • packages/services/src/db/models/location.ts: the tenant region row is now region → countries[] (was territories[]).
  • packages/services/src/db/services/location/index.ts: countries are canonicalised against the standard list (BR-15); a country may belong to at most one region per tenant (BR-16 — create/update refuse with the clashing region named); renaming a region cascades to the denormalised location.region strings on users and leads in the same operation; delete is refused while the region still holds countries or people, with the reason.
  • packages/services/src/utils/validators.ts (+ tests): validateRegionCountry replaces validateRegionTerritory — country must be on the standard list; when both are set the country must belong to the region (BR-17); an ungrouped country with no region is valid.
  • user.ts / lead.ts models: location.territory and location.state removed — Region and Country are the only two levels. Street/zip stay as contact info (D-13).
  • Migration 1787616000000-retire-territory-and-state.ts: unsets stored territory/state values on users and leads; swaps the locations collection's territories[] for an empty countries[] (territory names are not countries — admins group countries afterwards).
  • matching/score-expert-fit.ts: the prompt's territory lines now read country — no other matching change (whole-scope out-of-scope).
  • apps/web: the admin Locations tab now manages regions of countries (components/admin/regions-list/ replaces territories-list/; actions moved to app/(app)/admin/settings/locations/actions.ts); the user profile's region/country are pickers (region from the org's regions, country narrowed to the region's countries, region change clears a country that no longer belongs); updateTenantUser enforces BR-15/BR-17 server-side; the lead header, proposal detail and lead import lose territory (import gains a Country column dependent on Region). The legacy /admin/territories redirect page stays for bookmarks.

Acceptance criteria status

  • Location everywhere is region and country only — no screen offers or displays territory or state; the fields are gone from both schemas and the migration removes stored values.
  • Countries are chosen from the standard worldwide list, with no free typing — pickers in the region manager, user profile and lead import; server-side canonicalisation.
  • An administrator can create a region and group countries into it — the Locations tab manager.
  • A region still holding countries or people cannot be deleted — service guard with the reason; the delete control is disabled with "empty it before deleting" while countries remain.
  • A country's region cannot conflict with the region set on the same record — BR-17 validation on user updates and lead import; the UI clears a country its new region doesn't hold.
  • A country not yet grouped into any region can still be recorded on a person or lead — valid state in validateRegionCountry and the profile picker.
  • Renaming a region carries the new name through everywhere immediately — the update cascades to users' and leads' region strings.

Notes for Verify

  • The rename cascade and the one-region-per-country check read/write through the app models (not aggregations), so tenant scoping is plugin-enforced.
  • The migration's down restores shape, not retired values — stated in its header; backups are the record.
  • LocationService.softDelete keeps its name (it still deactivates rather than removing the row) but now carries the delete guards; the UI copy says "Delete".
  • Stacked on user-record-and-reporting-line — this PR's diff is against that branch.