# NexGate Location Service — Implementation Spec

> **For the implementer (Claude Code):** read this whole file before writing code.
> Follow the existing conventions of the NexGate codebase (package layout, REST path style,
> auth/security, error format, ID type, DTO style, tests). Where this spec and the codebase
> conventions differ on *style*, follow the codebase. Where they differ on *behaviour/rules*,
> follow this spec. Keep it simple — no approval workflows, no extra services, no over-engineering.

---

## 0. Summary

The Location Service is a **module inside the existing Spring Boot backend** (not a microservice).
It owns everything about *where* things are:

- Tanzania ward boundaries (official NBS 2022 data) and **pin → region/district/ward** resolution
- **Shop location** (one per shop, mandatory before a shop can be published)
- **Buyer saved addresses** (many per buyer, optional) and **recent locations**
- **Order address snapshots** (frozen copy of pickup/drop-off for an order)
- Privacy rules for who may see which part of a location

Shipping (providers, quotes, tracking) is **out of scope** — it will be built later *on top of* this service.

### 0.1 Where the Location Service is used across NexGate

It is the shared foundation for every feature that cares about *where*. Build the two core calls —
`resolve(pin) → ward/district/region` and `areaMatch(areaSet, pin-or-area) → bool` — as reusable
functions, because all of these call them:

- **Shop setup** — the shop's mandatory pickup location (pin → ward).
- **Buyer addresses & checkout** — saved/recent delivery addresses; the delivery pin.
- **Order snapshots** — frozen pickup + drop-off for every order.
- **Shipping — coverage** *(later)* — is a trip inside a shipper's areas / route stops?
- **Shipping — pricing** *(later)* — pin-to-pin distance for distance-based fees.
- **Shipping — trip type** *(later)* — same district / same region / cross-region, from the two wards.
- **Free-shipping zones** *(later)* — seller or NexGate "free within these areas / X km of shop".
- **"Near me" feed & search** *(later)* — show shops/products by the viewer's rough area.
- **Geo-targeted ads / boosting** *(later)* — show a boosted post only in chosen areas, or only where
  the seller can deliver; match on the viewer's **rough area only**, aggregate counts, no tracking.
- **Reports & analytics** — group orders/activity by ward/district/region codes (never per person).

> Everything above reuses the **same ward data and the same two functions** — no new map work per
> feature. Only the two core calls are in scope now; the rest are listed so the API is designed to
> serve them.

---

## 1. Principles (non-negotiable)

1. **The pin (lat/lng) is the truth.** Every saved location has a pin confirmed by the user on a map.
   No location is ever saved without coordinates.
2. **We take location only for a reason.** No location request on app open, no background tracking
   for buyers or shops, no movement trails. Store only the pin the user *confirmed*.
3. **Hierarchy comes from our own data, not Google.** Region/district/ward are computed on the
   backend from NBS ward shapes. Google is used **only on the client** (map display, place search,
   live label while placing the pin). The backend **never calls Google** and **never stores Google text**.
4. **Orders keep their own frozen copy** of addresses. Editing or deleting a saved address never
   changes past orders.
5. **Simple.** One module, plain SQL for spatial work, no Flyway, no version tracking, no JPA entity
   for the ward table.

---

## 2. Architecture

```
┌──────────────────────── CLIENTS (Android · iOS · Web) ────────────────────────┐
│  Pin screen: Google Map + Places search + live Google label (display only)    │
│  Sends: confirmed lat/lng + user text (landmark/note/phone/label)             │
└───────────────────────────────────────┬───────────────────────────────────────┘
                                        │ REST
┌───────────────────────────────────────▼───────────────────────────────────────┐
│                     NexGate Spring Boot backend                                │
│                                                                                │
│  ┌──────────────────────── location module ───────────────────────────────┐  │
│  │  WardResolver        pin → ward/district/region (PostGIS query)         │  │
│  │  LocationValidator   coverage, nearest-ward snap, device-distance check │  │
│  │  UserAddressService  saved addresses, default, duplicates, limits       │  │
│  │  RecentLocationSvc   last unsaved pins (max 5, 60 days)                 │  │
│  │  ShopLocationService one mandatory location per shop                    │  │
│  │  SnapshotFactory     frozen AddressSnapshot for orders                  │  │
│  │  WardDataSeeder      creates tables/indexes + loads ward file once      │  │
│  └────────────────────────────────┬────────────────────────────────────────┘  │
│                                   │ (internal Java calls)                      │
│  ┌────────────── orders / checkout ─────────┐   ┌──── shipping (FUTURE) ────┐ │
│  │ stores AddressSnapshot on each order      │──►│ reads snapshots only      │ │
│  └───────────────────────────────────────────┘   └───────────────────────────┘ │
└───────────────────────────────────────┬───────────────────────────────────────┘
                                        │ JDBC
┌───────────────────────────────────────▼───────────────────────────────────────┐
│  PostgreSQL + PostGIS (Docker image postgis/postgis)                           │
│  tz_wards · shop_locations · user_addresses · recent_locations                 │
└────────────────────────────────────────────────────────────────────────────────┘

   Ward data file (NOT in Git):  tz_wards_2022.geojson.gz  → mounted read-only into app container
```

### Who decides what

```
              📍 confirmed pin (lat, lng)
             /                          \
   NBS ward shapes (ours, backend)     Google (client only)
   region, district, ward, codes        "Mlimani City, Sinza Mori…"
        │                                    │
     STORED (truth)                    shown on pin screen only, never sent/stored
```

---

## 3. Ward data

### 3.1 Source
- National Bureau of Statistics (NBS) Tanzania — *2022 Population and Housing Census, Tanzania Wards (Shapefiles)*.
- Already converted for us to **GeoJSON, WGS84 lat/lng (EPSG:4326)**, 21 self-intersecting shapes repaired.
- File: **`tz_wards_2022.geojson.gz`** (~6.7 MB gzipped, ~30 MB raw). 4,344 features.
- Attribution required somewhere in the app's legal/about page: *"Ward boundaries © National Bureau of Statistics, Tanzania (2022 PHC)."*

### 3.2 File format (what the seeder reads)

```json
{
  "type": "FeatureCollection",
  "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
  "features": [
    {
      "type": "Feature",
      "properties": {
        "ward_id": 1, "nbs_code": "010111011",
        "reg_code": "01", "reg_name": "Dodoma",
        "dist_code": "01", "dist_name": "Kondoa",
        "counc_code": "11", "counc_name": "Halmashauri ya Wilaya ya Kondoa",
        "ward_code": "011", "ward_name": "Changaa"
      },
      "geometry": { "type": "Polygon", "coordinates": [[[35.6782, -4.736511], ...]] }
    }
  ]
}
```

### 3.3 Data facts the code must respect
| Fact | Consequence |
|---|---|
| Coordinates are **[longitude, latitude]** (GeoJSON order) | `ST_MakePoint(lng, lat)` — longitude first. Most common bug. |
| Geometry is a mix of `Polygon` and `MultiPolygon` | Always store with `ST_Multi(...)` into a `MultiPolygon` column. |
| `ward_code` alone is NOT unique; even `nbs_code` repeats for 5 wards (NBS data issue) | **`ward_id` (1..4344) is the primary key.** `nbs_code` is informational, not unique. |
| Shapes stop at shorelines (sea, Lake Victoria, etc.) | Pins on beaches/jetties may fall outside every ward → nearest-ward fallback (§6). |
| 31 regions, ~4,344 wards. Dar es Salaam has 5 councils (Ilala, Kinondoni, Temeke, Ubungo, Kigamboni) | Expected counts for sanity tests. |

### 3.4 District & region come from the ward rows — no separate files

There is **no separate district or region boundary table**. Every ward row already carries its
council, district and region names and codes, so a single `ST_Contains` lookup returns all levels
at once:

```
pin → ward (Kariakoo) → already has dist_name=Ilala, reg_name=Dar es Salaam, codes included
```

- **Coverage checks** ("shipper serves Dar es Salaam region") match `reg_code`/`dist_code` on the
  resolved ward — no shape needed.
- **Reports** group orders by the stored `reg_code`/`dist_code`.
- **Drawing** a district or region outline on a map (e.g. shipper coverage) is the only case that
  needs a shape. Build it on demand by dissolving wards — do NOT store a second dataset:

```sql
-- district outlines (view or materialized view, optional)
SELECT reg_code, reg_name, dist_code, dist_name, ST_Union(geom) AS geom
FROM tz_wards GROUP BY reg_code, reg_name, dist_code, dist_name;

-- region outlines
SELECT reg_code, reg_name, ST_Union(geom) AS geom
FROM tz_wards GROUP BY reg_code, reg_name;
```

Use `counc_code` as well if councils (halmashauri) matter for coverage; the ward rows carry it too.

---

## 4. Infrastructure

### 4.1 Docker
- Database image must include PostGIS: use **`postgis/postgis:<PG_MAJOR>-<POSTGIS>`** with the **same Postgres major
  version** currently used (e.g. `postgres:16` → `postgis/postgis:16-3.4`). Same major version keeps existing data volumes working.
- App container mounts the data folder read-only:

```yaml
services:
  db:
    image: postgis/postgis:16-3.4        # match current PG major version
  app:
    volumes:
      - /opt/nexgate/geo:/app/geo:ro      # host folder holding tz_wards_2022.geojson.gz
    environment:
      GEO_WARDS_FILE: /app/geo/tz_wards_2022.geojson.gz
```

- Local dev: put the file in `./data/geo/` and add `data/geo/` to **`.gitignore`**. Never commit the data file.

### 4.2 Config
```properties
# path to .geojson or .geojson.gz ; empty/missing → seeder logs WARN and skips
geo.wards-file=${GEO_WARDS_FILE:./data/geo/tz_wards_2022.geojson.gz}
```

### 4.3 No Flyway, no version tracking
Schema for this module is created by the **WardDataSeeder** at startup using idempotent SQL
(`IF NOT EXISTS`). It runs after Hibernate initialises (use `ApplicationRunner` or `ApplicationReadyEvent`).

- `tz_wards` has **no JPA entity** (read via `JdbcTemplate`/native SQL only), so Hibernate never touches it.
- `shop_locations`, `user_addresses`, `recent_locations` may be JPA entities following project conventions,
  with **plain `lat`/`lng` double columns** (no hibernate-spatial needed). Spatial work is done in native SQL
  using `ST_SetSRID(ST_MakePoint(lng, lat), 4326)`. The seeder adds the spatial expression indexes afterwards.

---

## 5. Database

### 5.1 Entity relationships

```mermaid
erDiagram
    TZ_WARDS ||--o{ SHOP_LOCATIONS : "resolved to"
    TZ_WARDS ||--o{ USER_ADDRESSES : "resolved to"
    TZ_WARDS ||--o{ RECENT_LOCATIONS : "resolved to"
    USERS ||--o{ USER_ADDRESSES : owns
    USERS ||--o{ RECENT_LOCATIONS : has
    SHOPS ||--|| SHOP_LOCATIONS : "has exactly one"
    ORDERS ||--|| ADDRESS_SNAPSHOT : "pickup (frozen copy)"
    ORDERS ||--|| ADDRESS_SNAPSHOT : "drop-off (frozen copy)"
```

Addresses store `ward_id` **and** copies of the names/codes (denormalised) so reads never need a join
and display stays stable.

### 5.2 `tz_wards` (created + filled by seeder)

```sql
CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE IF NOT EXISTS tz_wards (
    ward_id     INTEGER PRIMARY KEY,
    nbs_code    VARCHAR(16)  NOT NULL,
    reg_code    VARCHAR(4)   NOT NULL,
    reg_name    VARCHAR(100) NOT NULL,
    dist_code   VARCHAR(4)   NOT NULL,
    dist_name   VARCHAR(100) NOT NULL,
    counc_code  VARCHAR(4)   NOT NULL,
    counc_name  VARCHAR(200) NOT NULL,
    ward_code   VARCHAR(8)   NOT NULL,
    ward_name   VARCHAR(100) NOT NULL,
    geom        geometry(MultiPolygon, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tz_wards_geom ON tz_wards USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_tz_wards_names ON tz_wards (reg_name, dist_name, ward_name);
```

### 5.3 Location tables (logical schema — use project ID type & audit conventions)

**`shop_locations`** — exactly one per shop
| column | type | rule |
|---|---|---|
| id | project ID type | PK |
| shop_id | FK → shops | **unique** |
| lat, lng | double | required, rounded to 6 decimals |
| ward_id | int | resolved by backend |
| reg_name, dist_name, ward_name, nbs_code | text | copied from ward |
| match_type | enum `CONTAINED` / `NEAREST` | from resolver |
| landmark | text (≤ 300) | **required** ("Near Shekilango stop, above Vodashop, 1st floor") |
| building | text (≤ 100) | optional ("Shop 12") |
| pickup_phone | text | **required**, E.164 (+255…) |
| official_address | text (≤ 100) | optional (Anwani za Makazi) |
| photo_url | text | optional (shop front photo, uses existing media upload) |
| created_at, updated_at | timestamp | |

**`user_addresses`** — buyer's saved addresses (0..10 per user)
| column | type | rule |
|---|---|---|
| id | project ID type | PK |
| user_id | FK → users | |
| label_type | enum `HOME` / `WORK` / `OTHER` | HOME and WORK max **one each** per user |
| custom_label | text (≤ 40) | required when `OTHER` ("Mama's place") |
| lat, lng | double | required, 6 decimals |
| ward_id, reg_name, dist_name, ward_name, nbs_code, match_type | | resolved by backend |
| note | text (≤ 300) | optional note for rider ("Black gate, call on arrival") |
| receiver_name | text | defaults to user's name |
| receiver_phone | text | defaults to user's phone, E.164 |
| is_default | boolean | at most one `true` per user |
| last_used_at | timestamp | updated when used in an order |
| created_at, updated_at | timestamp | |

**`recent_locations`** — unsaved delivery pins (max 5 per user, expire after 60 days)
| column | type | rule |
|---|---|---|
| id | project ID type | PK |
| user_id | FK → users | |
| lat, lng, ward_id, reg_name, dist_name, ward_name, match_type | | as above |
| note, receiver_name, receiver_phone | | as used in that order |
| last_used_at | timestamp | |

Indexes created by seeder after Hibernate (idempotent):
```sql
CREATE UNIQUE INDEX IF NOT EXISTS ux_shop_locations_shop ON shop_locations (shop_id);
CREATE INDEX IF NOT EXISTS idx_shop_locations_geog ON shop_locations
    USING GIST ((ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography));   -- for future "near me"
CREATE INDEX IF NOT EXISTS idx_user_addresses_user ON user_addresses (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS ux_user_addresses_default ON user_addresses (user_id) WHERE is_default;
CREATE UNIQUE INDEX IF NOT EXISTS ux_user_addresses_home ON user_addresses (user_id) WHERE label_type = 'HOME';
CREATE UNIQUE INDEX IF NOT EXISTS ux_user_addresses_work ON user_addresses (user_id) WHERE label_type = 'WORK';
CREATE INDEX IF NOT EXISTS idx_recent_locations_user ON recent_locations (user_id, last_used_at DESC);
```
(If the project creates these tables itself via its own mechanism, keep the indexes in the seeder anyway.)

### 5.4 `AddressSnapshot` (frozen copy stored on orders)
Not a table owned by this module. The orders module stores it (e.g. a `jsonb` column `pickup_snapshot` /
`dropoff_snapshot`, or embedded columns — follow project convention). **Immutable once stored.**

```json
{
  "lat": -6.774512, "lng": 39.241871,
  "wardId": 1131, "nbsCode": "070113062",
  "regionName": "Dar es Salaam", "districtName": "Kinondoni", "wardName": "Kijitonyama",
  "matchType": "CONTAINED",
  "label": "Home",                         // null if unsaved
  "landmarkOrNote": "Black gate, call on arrival",
  "building": null,
  "contactName": "Kibuti", "contactPhone": "+2557XXXXXXXX",
  "sourceType": "USER_ADDRESS",            // USER_ADDRESS | NEW_PIN | SHOP_LOCATION
  "sourceId": "…",                         // id of saved address/shop location, null for NEW_PIN
  "capturedAt": "2026-10-08T12:30:00Z"
}
```

---

## 6. Pin resolution (core algorithm)

```
 input: lat, lng
   │
   ├─ basic checks: lat ∈ [-90,90], lng ∈ [-180,180], not (0,0) ── fail → 400 INVALID_COORDINATES
   │
   ├─ round to 6 decimals
   │
   ├─ Query A: ward whose shape CONTAINS the point
   │     found → matchType = CONTAINED, distanceM = 0  ───────────────────────► return ward
   │
   ├─ Query B: nearest ward within 500 m (shoreline, beach, jetty, border gaps)
   │     found → matchType = NEAREST, distanceM = n  ─────────────────────────► return ward
   │
   └─ nothing → OUTSIDE_COVERAGE (sea, lake, outside Tanzania)
```

```sql
-- Query A (uses GIST index)
SELECT ward_id, nbs_code, reg_name, dist_name, counc_name, ward_name
FROM tz_wards
WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326))
LIMIT 1;

-- Query B (only if A returns nothing)
SELECT ward_id, nbs_code, reg_name, dist_name, counc_name, ward_name,
       ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) AS distance_m
FROM tz_wards
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, 500)
ORDER BY distance_m
LIMIT 1;
```

Point exactly on a border → whichever ward Query A returns is fine.
Constant: `NEAREST_WARD_MAX_METERS = 500`.

### 6.1 Validation rules
| Check | Applies to | Result |
|---|---|---|
| Invalid coordinates | all | **block** `INVALID_COORDINATES` |
| No ward within 500 m | all | **block** `OUTSIDE_COVERAGE` ("We don't deliver here yet") |
| Matched by NEAREST | all | allow, `matchType=NEAREST` (info only) |
| Pin > **1 km** from device position (client sends optional `deviceLat/deviceLng`) | **shop only** | allow, return warning `FAR_FROM_DEVICE` with distance; client asks "Your pin is 3.2 km from where you are. Is this your shop?" |
| Saved address within **50 m** of another saved address of same user | buyer save | `409 NEAR_DUPLICATE` with the existing address; client asks "This looks like 🏠 Home. Use it?"; client may resend with `allowNearDuplicate=true` |
| More than 10 saved addresses | buyer save | block `ADDRESS_LIMIT_REACHED` |
| Second HOME or WORK | buyer save/edit | block `LABEL_ALREADY_USED` |

Client-only checks (not backend): GPS accuracy circle, minimum zoom before confirm, satellite toggle.

---

## 7. Business rules

### 7.1 Shop location
- Exactly **one** location per shop. **Required to publish a shop** — expose `hasLocation(shopId)` for the shop module.
- Required fields: pin, landmark, pickup phone. Optional: building, official address, photo.
- Only the shop owner (and shop admins per existing roles) can set/edit it. No approval step.
- Editing affects **future** orders only (old orders keep snapshots).

### 7.2 Buyer addresses
- Saving is **optional** and the buyer's choice (checkbox "Save for next time").
- Max 10 saved. HOME/WORK at most once each; OTHER needs a custom label.
- First saved address becomes default automatically. Buyer can change default.
  If the default is deleted → no default (client pre-selects most recently used).
- Receiver name/phone default to the buyer's profile; buyer can change ("someone else").
- Edit = change pin (re-resolved), label, note, receiver. Delete = **hard delete**.

### 7.3 Recent locations
- When an order uses a **new pin that was not saved**, store it in `recent_locations`.
- Keep max **5** per user (delete oldest beyond 5 on insert). Ignore and delete entries older than **60 days**
  (on read/insert — no scheduler needed).
- If the new pin is within 50 m of an existing recent, update that one's `last_used_at` instead of inserting.
- Buyer can delete one or clear all. A recent can be promoted to a saved address (client posts a normal create).

### 7.4 Orders / snapshots
- Checkout passes either `addressId` (saved), `recentId`, or a **new pin** payload.
- Location module returns an `AddressSnapshot`; orders module stores it; snapshot never changes.
- Using a saved address updates its `last_used_at`.
- Pickup snapshot comes from the shop location (`sourceType=SHOP_LOCATION`).

### 7.5 Account deletion
Deletes all `user_addresses` and `recent_locations` of that user. Order snapshots remain (order records).

---

## 8. Privacy & visibility

| Data | Buyer | Seller | Rider / delivery provider | Third parties |
|---|---|---|---|---|
| Buyer saved addresses / recents | own only | ❌ | ❌ | ❌ |
| Order drop-off **area** (ward, district, region) | ✅ | ✅ | ✅ | ❌ |
| Order drop-off **exact pin + note + phone** | ✅ | only if seller delivers itself (`sellerDelivers=true`) | ✅ for that shipment only | ❌ |
| Shop location (pin, landmark, ward) | ✅ public | ✅ | ✅ | ❌ |
| Shop pickup phone | after order | ✅ | ✅ | ❌ |

Implement as two snapshot views: `AddressSnapshotPublicView` (area only) and `AddressSnapshotFullView`.
Analytics/reports aggregate by ward/district only — never per person.
Never log full coordinates together with user identifiers at INFO level.

---

## 9. REST API

> Paths shown as examples — adapt prefix/style/auth to the existing project.
> All endpoints require authentication except `GET /shops/{id}/location` (public view).

### 9.1 Resolve (used live by pin screen after the map stops moving — debounce ~400 ms client side)
`GET /api/v1/locations/resolve?lat=-6.8190&lng=39.2768`
```json
200 { "covered": true, "matchType": "CONTAINED", "distanceM": 0,
      "ward": { "wardId": 1146, "nbsCode": "070214112", "wardName": "Kariakoo",
                "districtName": "Ilala", "councilName": "Halmashauri ya Jiji la Dar es Salaam",
                "regionName": "Dar es Salaam" },
      "areaLabel": "Kariakoo, Ilala, Dar es Salaam" }
200 { "covered": false, "reason": "OUTSIDE_COVERAGE" }
```
Nothing is stored by this call.

### 9.2 Buyer addresses
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/me/addresses` | list, default first, then `last_used_at` desc |
| POST | `/api/v1/me/addresses` | create (body below) |
| PUT | `/api/v1/me/addresses/{id}` | update (same body) |
| DELETE | `/api/v1/me/addresses/{id}` | hard delete |
| POST | `/api/v1/me/addresses/{id}/default` | make default |
| GET | `/api/v1/me/recent-locations` | max 5, ≤ 60 days |
| DELETE | `/api/v1/me/recent-locations/{id}` | remove one |
| DELETE | `/api/v1/me/recent-locations` | clear all |

```json
POST /api/v1/me/addresses
{ "lat": -6.774512, "lng": 39.241871,
  "labelType": "OTHER", "customLabel": "Mama's place",
  "note": "Black gate, call on arrival",
  "receiverName": null, "receiverPhone": null,      // null → use profile
  "makeDefault": false, "allowNearDuplicate": false }
```

### 9.3 Shop location
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/shops/{shopId}/location` | public view (no phone unless authorised) |
| PUT | `/api/v1/shops/{shopId}/location` | create or replace; owner only |

```json
PUT /api/v1/shops/{shopId}/location
{ "lat": -6.770901, "lng": 39.229410,
  "landmark": "Near Shekilango stop, above Vodashop, 1st floor",
  "building": "Shop 12", "pickupPhone": "+2557XXXXXXXX",
  "officialAddress": null, "photoUrl": null,
  "deviceLat": -6.7712, "deviceLng": 39.2291 }       // optional, for FAR_FROM_DEVICE warning
→ 200 { ...location..., "warnings": [ { "code": "FAR_FROM_DEVICE", "distanceM": 3200 } ] }
```

### 9.4 Internal Java API (for orders/shop modules — not REST)
```java
ResolveResult resolve(double lat, double lng);
boolean hasShopLocation(ShopId shopId);
AddressSnapshot snapshotFromShop(ShopId shopId);
AddressSnapshot snapshotFromAddress(UserId userId, AddressId addressId);   // also bumps last_used_at
AddressSnapshot snapshotFromRecent(UserId userId, RecentId recentId);
AddressSnapshot snapshotFromNewPin(UserId userId, NewPinRequest req);      // req.saveForLater → creates address, else records recent
```

### 9.5 Error codes
`INVALID_COORDINATES`, `OUTSIDE_COVERAGE`, `NEAR_DUPLICATE` (409, includes existing address),
`ADDRESS_LIMIT_REACHED`, `LABEL_ALREADY_USED`, `SHOP_LOCATION_REQUIRED` (thrown by shop publish),
`NOT_FOUND`, `FORBIDDEN`. Use the project's existing error response format.

---

## 10. WardDataSeeder (startup)

```
App starts (after Hibernate)
  │
  ├─ 1. CREATE EXTENSION IF NOT EXISTS postgis          (log clear error if not permitted)
  ├─ 2. CREATE TABLE IF NOT EXISTS tz_wards …  + indexes
  ├─ 3. CREATE INDEX IF NOT EXISTS … on location tables (§5.3)
  │
  ├─ 4. BEGIN; SELECT pg_advisory_xact_lock(<constant>)   ← only one container seeds
  │      SELECT count(*) FROM tz_wards
  │        ├─ > 0  → COMMIT, done (normal startup, instant)
  │        └─ = 0  → file exists?
  │                    ├─ no  → WARN "ward file not found at …; ward lookup disabled", COMMIT, continue startup
  │                    └─ yes → stream features (Jackson streaming parser, gzip if *.gz)
  │                             batch insert 200 rows:
  │                             INSERT INTO tz_wards (…, geom)
  │                             VALUES (…, ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(?), 4326)))
  │                             ON CONFLICT (ward_id) DO NOTHING
  │                             COMMIT; ANALYZE tz_wards
  │                             log "Loaded N wards in X s" (expect 4344)
  │
  └─ 5. If wards were just loaded AND user_addresses/shop_locations/recent_locations already have rows
         → re-resolve all of them (update ward_id + name copies + match_type). Order snapshots untouched.
```

Rules:
- Stream the file — never load all 30 MB into memory.
- Accept both `.geojson` and `.geojson.gz`.
- Seeder failure must not crash the app; resolve endpoint then returns `503 WARD_DATA_UNAVAILABLE`.
- **Updating to new boundaries later:** replace file on server, `TRUNCATE tz_wards;`, restart. Step 5 re-resolves saved locations.

---

## 11. Client behaviour (Android · iOS · Web)

### 11.1 Location permission
- **Never** ask on app open. Ask only when the user taps **"Use my location"** on the pin screen.
- Show our own explainer first; trigger the OS/browser prompt only if the user agrees.
- **Foreground / "While using the app" only.** Never background location for buyers or shops.
- Handle **approximate** location (Android 12+, iOS 14+): use it to centre the map, ask user to drag map to exact spot.
- iOS: purpose string e.g. *"NexGate uses your location to set your shop or delivery address faster."*
- Web: only after a click, HTTPS only. If denied, search + map always still work. Location is a shortcut, never a gate.

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆           📍                  ┆
   Use your location?
┆ We use it once to place your  ┆
   pin faster. You can also
┆ search or move the map.       ┆
  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆ ┆   Use my location         ┆ ┆   → OS permission prompt
  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┆        Not now              ┆     → stay on map, no prompt
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

### 11.2 Pin screen (shared component — shop, buyer, add-address)
Behaviour:
1. **Fixed centre pin, the map moves** (Bolt/Uber pattern). Pin lifts while dragging, drops when the map stops.
2. Start position: accurate GPS → last used location → centre of user's city. Never whole-country view.
3. **Map / Satellite toggle** (satellite matters — many streets unnamed).
4. GPS **accuracy circle**; if accuracy > 100 m show "Location is approximate — move the map to your exact spot".
5. **Confirm disabled until zoom ≥ 17** (street level). Hint: "Zoom in to place exactly".
6. Search (Google Places) only **moves** the map: "Now move the map to the exact entrance".
7. When map stops (debounce ~400 ms): show Google label + call `/locations/resolve`.
   If `covered=false` → disable Confirm, show "We don't deliver here yet".

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  Shop location     Step 3/4
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ 🔍 Search place or area   ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆ ┆ [Map|Satellite]           ┆ ┆
  ┆                           ┆
┆ ┆        ( ◌ )  ← accuracy  ┆ ┆
  ┆          📍  fixed centre ┆
┆ ┆        move the map       ┆ ┆
  ┆                    [◎]    ┆   [◎] = use my location
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  ⚠ Zoom in to place exactly
┆   (shown until zoom ≥ 17)     ┆
  📍 Sinza, Ubungo, Dar es Salaam      ← Google label (display only)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆   Confirm location   →    ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

### 11.3 Shop flow (mandatory, step in shop creation)

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  Shop location     Step 3/4
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ [mini map 📍]   Change    ┆
┆ ┆ Makurumla, Ubungo, Dar    ┆ ┆   ← from /resolve (NBS)
  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┆ Directions / landmark *       ┆
  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆ ┆ Near Shekilango stop,     ┆ ┆
  ┆ above Vodashop, 1st floor ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Building / shop no. (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ Shop 12                   ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Pickup contact phone *
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ +255 7XX XXX XXX          ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Official address (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ Anwani za Makazi          ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  📷 Add shop front photo (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆   Save & continue    →    ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```
If response has `FAR_FROM_DEVICE` → dialog "Your pin is 3.2 km from where you are. Is this your shop?" [Yes, it's correct] [Adjust pin].

### 11.4 Buyer delivery flow (pin only, Bolt style)

**Checkout — choose where**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  Deliver to
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ ● 🏠 Home      (default)   ┆
┆ ┆   Mbezi Juu, Ubungo       ┆ ┆
  ┆ ○ 💼 Work                  ┆
┆ ┆   Kivukoni, Ilala         ┆ ┆
  ┆ ○ ⭐ Mama's place          ┆
┆ ┆   Tabata, Ilala           ┆ ┆
  ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
┆ ┆ Recent                    ┆ ┆
  ┆ ○ 🕘 Kijitonyama   [Save]  ┆
┆ ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ ┆
  ┆ ＋ New location (pin)      ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
         Manage addresses
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```
First-time buyer with nothing saved → goes straight to the pin screen.

**New location — full map + bottom sheet**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  Deliver to       [Sat]
┆                               ┆
          FULL MAP
┆           📍                  ┆
                         [◎]
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ 🔍 Search                 ┆
┆ ┆ 🏠 Home     Mbezi Juu      ┆ ┆   ← tap jumps the pin there
  ┆ 💼 Work     Kivukoni       ┆
┆ ┆ 🕘 Recent   Kijitonyama    ┆ ┆
  ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
┆ ┆ 📍 Mikocheni, Kinondoni    ┆ ┆
  ┆ [ Deliver here → ]        ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

**Quick confirm sheet**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  📍 Mikocheni, Kinondoni
┆                               ┆
  Note for rider (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ Black gate, call on arrival┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Receiver: Me (+255 7XX…)  ✎        ← "Someone else" → name + phone
┆                               ┆
  [ ] Save as  🏠  💼  ✎             ← labels shown only when ticked
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆      Continue  →          ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

**Profile — manage addresses**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  My addresses
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ 🏠 Home  ✓default     ⋮   ┆
┆ ┆ Mbezi Juu, Ubungo         ┆ ┆
  ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
┆ ┆ 💼 Work               ⋮   ┆ ┆
  ┆ Kivukoni, Ilala           ┆
┆ ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ ┆
  ┆ ⭐ Mama's place        ⋮   ┆
┆ ┆ Tabata, Ilala             ┆ ┆
  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┆   ⋮ = Edit pin · Rename ·     ┆
    Set default · Delete
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆   ＋ Add address           ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  🕘 Clear recent locations
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

**Optional secondary onboarding (buyer)**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆           📦                  ┆
   Faster checkout?
┆  Add your home address now    ┆
   and skip it at checkout.
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆      Add address          ┆      → pin screen → quick confirm (Save always on)
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
           Maybe later
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

Area lines shown after saving (lists, checkout) are built from **NBS names** (`ward, district`) plus the
user's label — never from Google text.

---

## 12. Flows

### 12.1 Shop sets location
```mermaid
sequenceDiagram
    actor Owner
    participant App
    participant Google as Google Maps/Places (client)
    participant API as Location module
    participant DB as PostGIS
    Owner->>App: Step 3 "Shop location"
    App->>Owner: Explainer → tap "Use my location"
    App->>App: OS permission (while in use), one GPS reading
    Owner->>App: Move map under fixed pin, zoom ≥ 17
    App->>Google: label for map centre (display only)
    App->>API: GET /locations/resolve?lat&lng
    API->>DB: ST_Contains / nearest ≤ 500 m
    DB-->>API: ward, district, region
    API-->>App: covered=true, areaLabel
    Owner->>App: Confirm → landmark, phone, (building, photo)
    App->>API: PUT /shops/{id}/location (+deviceLat/Lng)
    API->>DB: resolve again, upsert shop_locations
    API-->>App: saved (+ FAR_FROM_DEVICE warning if > 1 km)
```

### 12.2 Buyer checkout with a new pin
```mermaid
sequenceDiagram
    actor Buyer
    participant App
    participant Orders as Orders/Checkout
    participant Loc as Location module
    participant DB as PostGIS
    Buyer->>App: Checkout → "＋ New location"
    Buyer->>App: Place pin, note, receiver, [Save?]
    App->>Loc: GET /locations/resolve (live while moving)
    Buyer->>App: Continue / Pay
    App->>Orders: place order { newPin, saveForLater }
    Orders->>Loc: snapshotFromNewPin(user, req)
    Loc->>DB: resolve ward
    alt saveForLater = true
        Loc->>DB: insert user_addresses (rules §7.2)
    else
        Loc->>DB: upsert recent_locations (max 5, 60 days)
    end
    Loc-->>Orders: AddressSnapshot (frozen)
    Orders->>DB: store order + pickup & drop-off snapshots
```

### 12.3 Shop publish guard
```
Shop "Publish" → shopModule calls location.hasShopLocation(shopId)
   ├─ true  → publish
   └─ false → 400 SHOP_LOCATION_REQUIRED → app opens Shop location step
```

---

## 13. Out of scope (do NOT build now)
- Shipping, providers (Bolt etc.), quotes, tracking, recommendation engine
- Delivery zones and hubs (future: same pattern — shapes table + `ST_Contains`)
- Mtaa/street level (no public shapes; future table identical to `tz_wards` if NBS provides them)
- "Near me" feed (index on shop_locations is prepared; feature later — must use rough area only, not stored)
- Any backend call to Google; storing Google labels

---

## 14. Acceptance tests

### 14.1 Ward resolution (integration test against loaded data — these were verified against the file)
| Place | lat | lng | Expected ward | District | Region |
|---|---|---|---|---|---|
| Kariakoo market | -6.8190 | 39.2768 | Kariakoo | Ilala | Dar es Salaam |
| Ubungo bus terminal | -6.7889 | 39.2083 | Ubungo | Ubungo | Dar es Salaam |
| JNIA airport | -6.8781 | 39.2026 | Kipawa | Ilala | Dar es Salaam |
| Posta / city centre | -6.8160 | 39.2900 | Kivukoni | Ilala | Dar es Salaam |
| Stone Town | -6.1622 | 39.1887 | Shangani | Mjini | Mjini Magharibi |
| Arusha clock tower | -3.3700 | 36.6940 | Sekei | Arusha | Arusha |
| Mwanza centre | -2.5164 | 32.9006 | Nyamagana | Nyamagana | Mwanza |
| Mbeya town | -8.9094 | 33.4608 | Ruanda | Mbeya | Mbeya |
| Dodoma centre | -6.1722 | 35.7395 | Majengo | Dodoma | Dodoma |
| Indian Ocean off Dar | -6.80 | 39.40 | — `OUTSIDE_COVERAGE` | | |
| Nairobi | -1.2864 | 36.8172 | — `OUTSIDE_COVERAGE` | | |

Also: `SELECT count(*) FROM tz_wards` = **4344**; `SELECT count(DISTINCT reg_name)` = **31**.
Use Testcontainers with `postgis/postgis` image if the project already uses Testcontainers; otherwise follow project test setup.

### 14.2 Rules
- Seeder runs twice → still 4344 rows, second run instant.
- Two app instances start together → 4344 rows, no errors (advisory lock).
- Missing ward file → app starts, WARN logged, `/resolve` returns 503.
- Swapped lat/lng (39.2768, -6.8190) → `OUTSIDE_COVERAGE` (guards against order bug).
- 11th saved address → `ADDRESS_LIMIT_REACHED`. Second HOME → `LABEL_ALREADY_USED`.
- Save within 50 m of existing → `409 NEAR_DUPLICATE`; with `allowNearDuplicate=true` → saved.
- Delete default → no default remains. First saved address → becomes default.
- 6th unsaved pin → only 5 recents remain; entries > 60 days not returned.
- Edit saved address after an order → order snapshot unchanged.
- Shop without location cannot publish → `SHOP_LOCATION_REQUIRED`.
- Shop PUT with device 3 km away → saved + `FAR_FROM_DEVICE` warning.
- Seller view of order shows area only unless `sellerDelivers=true`.
- `TRUNCATE tz_wards` + restart → reload + saved addresses re-resolved.

---

## 15. Build order
1. Docker: switch DB to `postgis/postgis` (same PG major), mount `/opt/nexgate/geo`, add config, `.gitignore data/geo/`.
2. `WardDataSeeder` + `WardResolver` + `GET /locations/resolve` + tests §14.1. **← foundation**
3. Shop location (entity, PUT/GET, `hasShopLocation`, publish guard).
4. Buyer addresses + recents (+ rules tests).
5. `AddressSnapshot` + checkout integration + privacy views.
6. Clients: shared pin-screen component, shop step, checkout picker, manage addresses, optional onboarding.