---
title: Location, Map, and Geolocation Handling
impact: CRITICAL
impactDescription: Prevents data corruption, map rendering crashes, and UX failures with geographic data
tags: locations, maps, geolocation, nominatim, coordinates, address, validation
---
## Паспорт документа

- Статус документа: living standard
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: при изменении инженерной практики, CI/CD, архитектурных правил или локального workflow
- Область применения: внутренние rule/reference-card документы для инженерной команды
- Связанные документы:
  - [Индекс Agents](../README.md)
  - [Команды разработки](../commands.md)
  - [Инженерные принципы](../../governance/engineering-principles.md)

## Location, Map, and Geolocation Handling

**Impact: CRITICAL**

Locations are a core domain concept — learning centers, tutors, and group classes all have physical addresses. The platform uses OpenStreetMap/Nominatim for geocoding and map rendering. Past incidents have shown that incorrect location handling leads to broken map renders, missing pins, incorrect search results, and poor user experience.

---

### 1. Location Type System

Every item has a location type. Define these as an enum in `packages/shared`:

```typescript
// packages/shared/src/constants/location-types.ts
export enum LocationType {
  OFFLINE_ADDRESS = 'OFFLINE_ADDRESS',     // Physical address with coordinates
  ONLINE = 'ONLINE',                       // Online-only (no address needed)
  HYBRID = 'HYBRID',                       // Both physical and online
  MOBILE_TUTOR = 'MOBILE_TUTOR',           // Tutor comes to student's location
  MULTIPLE_BRANCHES = 'MULTIPLE_BRANCHES', // Learning center with multiple locations
}
```

**Rules:**
- Every item must have a `locationType` — never store location data without knowing the type
- `ONLINE` items must NOT have coordinates or address fields populated
- `OFFLINE_ADDRESS` items MUST have valid coordinates and address
- `MOBILE_TUTOR` items store the tutor's coverage area (city/district), not a precise address

---

### 2. Location Data Schema

Use a strict Zod schema for all location data. Never accept raw coordinates without validation.

```typescript
// packages/shared/src/schemas/location.ts
import { z } from 'zod';

// Uzbekistan bounding box (approximate)
const UZ_BOUNDS = {
  latMin: 37.0,
  latMax: 45.6,
  lonMin: 55.9,
  lonMax: 73.2,
};

export const CoordinatesSchema = z.object({
  lat: z.number()
    .min(UZ_BOUNDS.latMin, 'Latitude out of Uzbekistan bounds')
    .max(UZ_BOUNDS.latMax, 'Latitude out of Uzbekistan bounds'),
  lon: z.number()
    .min(UZ_BOUNDS.lonMin, 'Longitude out of Uzbekistan bounds')
    .max(UZ_BOUNDS.lonMax, 'Longitude out of Uzbekistan bounds'),
});

export const AddressSchema = z.object({
  fullAddress: z.string().min(5).max(500),
  city: z.string().min(2).max(100),
  district: z.string().max(100).optional(),
  coordinates: CoordinatesSchema,
  displayPublicly: z.boolean().default(true),
});

export const LocationSchema = z.object({
  type: z.nativeEnum(LocationType),
  address: AddressSchema.optional(),
  onlineUrl: z.string().url().optional(),
  coverageArea: z.string().max(200).optional(), // For MOBILE_TUTOR
  branches: z.array(AddressSchema).max(20).optional(), // For MULTIPLE_BRANCHES
}).refine(
  (data) => {
    if (data.type === 'OFFLINE_ADDRESS' || data.type === 'HYBRID') {
      return !!data.address;
    }
    return true;
  },
  { message: 'Address is required for offline/hybrid items' }
).refine(
  (data) => {
    if (data.type === 'ONLINE' || data.type === 'HYBRID') {
      return !!data.onlineUrl;
    }
    return true;
  },
  { message: 'Online URL is required for online/hybrid items' }
);

export type Coordinates = z.infer<typeof CoordinatesSchema>;
export type Address = z.infer<typeof AddressSchema>;
export type LocationData = z.infer<typeof LocationSchema>;
```

---

### 3. Coordinate Validation — NEVER Trust Raw Input

**This is the #1 source of past bugs.** Always validate coordinates before storing or rendering.

```typescript
// packages/shared/src/utils/coordinates.ts

export function isValidUzbekistanCoordinates(lat: number, lon: number): boolean {
  return (
    lat >= 37.0 && lat <= 45.6 &&
    lon >= 55.9 && lon <= 73.2 &&
    Number.isFinite(lat) && Number.isFinite(lon)
  );
}

export function sanitizeCoordinates(lat: unknown, lon: unknown): Coordinates | null {
  const parsedLat = Number(lat);
  const parsedLon = Number(lon);

  if (!Number.isFinite(parsedLat) || !Number.isFinite(parsedLon)) return null;
  if (!isValidUzbekistanCoordinates(parsedLat, parsedLon)) return null;

  // Round to 6 decimal places (accuracy ~0.1m, more than enough)
  return {
    lat: Math.round(parsedLat * 1e6) / 1e6,
    lon: Math.round(parsedLon * 1e6) / 1e6,
  };
}
```

**Rules:**
- **Never** store coordinates as strings — always `Float` in Prisma / `number` in TypeScript
- **Never** pass coordinates without validation — use `sanitizeCoordinates()` at every input boundary
- **Never** trust Nominatim response blindly — validate that returned coords fall within Uzbekistan bounds
- **Always** round to 6 decimal places — more precision is unnecessary and causes floating-point comparison issues
- **Always** use `(lat, lon)` order, never `(lon, lat)` — Nominatim returns `lat,lon` but GeoJSON uses `[lon, lat]`

---

### 4. Nominatim Geocoding Integration

Use Nominatim (OpenStreetMap) for address autocomplete and geocoding. **Respect Nominatim usage policy.**

```typescript
// apps/web/lib/nominatim.ts

const NOMINATIM_BASE = 'https://nominatim.openstreetmap.org';
const USER_AGENT = 'Qadam/1.0 (contact@qadam.uz)'; // Required by Nominatim policy

export async function searchAddress(query: string): Promise<NominatimResult[]> {
  // Rate limit: max 1 request per second (Nominatim policy)
  const params = new URLSearchParams({
    q: query,
    format: 'json',
    addressdetails: '1',
    limit: '5',
    countrycodes: 'uz', // Restrict to Uzbekistan
    'accept-language': 'uz,ru', // Prefer Uzbek, fallback to Russian
  });

  const res = await fetch(`${NOMINATIM_BASE}/search?${params}`, {
    headers: { 'User-Agent': USER_AGENT },
  });

  if (!res.ok) return [];

  const results = await res.json();
  return results
    .map(parseNominatimResult)
    .filter((r): r is NominatimResult => r !== null);
}

function parseNominatimResult(raw: any): NominatimResult | null {
  const coords = sanitizeCoordinates(raw.lat, raw.lon);
  if (!coords) return null; // Skip results outside Uzbekistan

  return {
    displayName: raw.display_name,
    coordinates: coords,
    city: raw.address?.city || raw.address?.town || raw.address?.village || '',
    district: raw.address?.suburb || raw.address?.district || '',
  };
}
```

**Nominatim Rules:**
- **Always** set `User-Agent` header with app name and contact email
- **Always** set `countrycodes=uz` to restrict to Uzbekistan
- **Always** debounce autocomplete requests (minimum 300ms between keystrokes)
- **Always** limit results to 5 (`limit=5`)
- **Never** make more than 1 request per second (Nominatim rate limit)
- **Never** cache Nominatim responses longer than 24 hours (their policy)
- **Never** bulk-geocode addresses — do it on-demand per user interaction
- **Always** provide `accept-language: uz,ru` for localized address names
- **Always** validate returned coordinates before using them

---

### 5. Map Rendering (Client Component)

Map rendering is always a Client Component — it requires browser APIs and interactivity.

```typescript
// components/QadamMap.tsx
"use client";

import { useEffect, useRef, useState } from 'react';
import type { Coordinates } from '@repo/shared';

interface MapProps {
  center: Coordinates;
  markers?: Array<{ coordinates: Coordinates; title: string; id: string }>;
  zoom?: number;
  interactive?: boolean; // false for detail pages, true for location picker
  onLocationSelect?: (coords: Coordinates) => void;
  className?: string;
}

export function QadamMap({
  center,
  markers = [],
  zoom = 14,
  interactive = false,
  onLocationSelect,
  className,
}: MapProps) {
  // Map implementation here...
}
```

**Map rendering rules:**
- **Always** wrap map in `"use client"` — maps need browser APIs
- **Always** provide a `center` prop — never render a map without a center point
- **Always** provide fallback UI for when coordinates are missing or invalid
- **Always** handle map loading state (skeleton/placeholder while tiles load)
- **Always** lazy-load map component (`dynamic(() => import('./QadamMap'), { ssr: false })`)
- **Never** render a map in a Server Component
- **Never** render a map with `[0, 0]` coordinates — show "no location" state instead
- **Never** block page render on map loading — use Suspense or lazy loading

```typescript
// Lazy loading pattern for map (prevents SSR issues)
import dynamic from 'next/dynamic';

const QadamMap = dynamic(() => import('@/components/QadamMap'), {
  ssr: false,
  loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded-lg" />,
});
```

---

### 6. Address Autocomplete Component

```typescript
// components/NominatimAutocomplete.tsx
"use client";

export function NominatimAutocomplete({
  onSelect,
  defaultValue,
}: {
  onSelect: (address: Address) => void;
  defaultValue?: string;
}) {
  const [query, setQuery] = useState(defaultValue || '');
  const [results, setResults] = useState<NominatimResult[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const debounceRef = useRef<NodeJS.Timeout>();

  const handleSearch = (value: string) => {
    setQuery(value);

    // Debounce: minimum 300ms between requests
    clearTimeout(debounceRef.current);
    if (value.length < 3) {
      setResults([]);
      return;
    }

    debounceRef.current = setTimeout(async () => {
      setIsLoading(true);
      const results = await searchAddress(value);
      setResults(results);
      setIsLoading(false);
    }, 300);
  };

  // ... render dropdown with results
}
```

**Autocomplete rules:**
- **Always** debounce with minimum 300ms delay
- **Always** require minimum 3 characters before searching
- **Always** show loading state during search
- **Always** handle empty results ("Адрес не найден")
- **Always** clear results when input is cleared
- **Never** fire search on every keystroke — always debounce

---

### 7. Prisma Schema for Locations

```prisma
model Item {
  id             String       @id @default(uuid())
  locationType   LocationType
  address        String?      // Full text address
  city           String?      // City name (for filtering)
  district       String?      // District/suburb
  latitude       Float?       // Validated coordinate
  longitude      Float?       // Validated coordinate
  onlineUrl      String?      // For ONLINE/HYBRID items
  locationId     String?      // FK to Location reference table
  location       Location?    @relation(fields: [locationId], references: [id])

  @@index([city, locationType])
  @@index([locationType])
}

model ItemBranch {
  id        String @id @default(uuid())
  itemId    String
  item      Item   @relation(fields: [itemId], references: [id])
  address   String
  city      String
  latitude  Float
  longitude Float

  @@index([itemId])
}

enum LocationType {
  OFFLINE_ADDRESS
  ONLINE
  HYBRID
  MOBILE_TUTOR
  MULTIPLE_BRANCHES
}
```

**Database rules:**
- `latitude` and `longitude` are `Float`, never `String`
- `city` is denormalized from address for fast filtering (indexed)
- Multiple branches stored in separate `ItemBranch` table, not JSON array
- Index on `[city, locationType]` for catalog filtering
- `locationId` references the reference table for standardized city/district names

---

### 8. Privacy and Display Control

Following cal.com's pattern, locations have a `displayPublicly` flag:

```typescript
// In response DTOs, filter private location details
function toPublicItemDTO(item: ItemWithLocation): ItemPublicDTO {
  return {
    id: item.id,
    title: item.title,
    // Show city always (for filtering/search)
    city: item.city,
    // Only show precise address if displayPublicly is true
    address: item.displayAddressPublicly ? item.address : undefined,
    coordinates: item.displayAddressPublicly
      ? { lat: item.latitude, lon: item.longitude }
      : undefined,
    locationType: item.locationType,
  };
}
```

**Privacy rules:**
- **Always** show city name (needed for search/filtering)
- **Never** show precise address/coordinates unless seller opts in via `displayPublicly`
- On item detail pages: show map only if address is public
- On catalog cards: show only city name, never full address

---

### 9. Location Change Audit

When a seller updates item location, log the change:

```typescript
@Injectable()
export class ItemService {
  async updateLocation(itemId: string, newLocation: LocationData, sellerId: string) {
    const item = await this.itemRepo.findById(itemId);
    if (!item) throw new DomainException(ErrorCode.ITEM_NOT_FOUND);
    if (item.sellerId !== sellerId) throw new DomainException(ErrorCode.FORBIDDEN);

    // Validate new coordinates
    if (newLocation.address?.coordinates) {
      const valid = isValidUzbekistanCoordinates(
        newLocation.address.coordinates.lat,
        newLocation.address.coordinates.lon
      );
      if (!valid) throw new DomainException(ErrorCode.INVALID_COORDINATES);
    }

    await this.itemRepo.updateLocation(itemId, newLocation);

    // Audit log
    await this.auditService.log({
      action: 'LOCATION_CHANGED',
      entityType: 'ITEM',
      entityId: itemId,
      performedBy: sellerId,
      oldValue: { address: item.address, lat: item.latitude, lon: item.longitude },
      newValue: newLocation.address,
    });

    // Invalidate caches
    await this.cache.invalidate(`item:${item.slug}`);
    await this.cache.invalidate('catalog:*');
  }
}
```

---

### 10. Common Mistakes and Past Incidents

| Incident | Cause | Prevention |
|----------|-------|------------|
| Map renders at `[0, 0]` (Atlantic Ocean) | Coordinates stored as `null`, map uses `0` as default | Always check for `null` before rendering map |
| Wrong pin placement | `lat`/`lon` swapped | Use typed `Coordinates` object, never positional args |
| Autocomplete returns results from Russia | Missing `countrycodes=uz` parameter | Always pass `countrycodes=uz` to Nominatim |
| Map crashes on SSR | Map library uses `window` object | Always lazy-load with `ssr: false` |
| Duplicate markers on re-render | State not cleaned between renders | Use `key` prop or `useRef` for map instance |
| Address displayed to public | Missing privacy check | Use `displayPublicly` flag in all DTOs |
| Rate limited by Nominatim | No debounce on autocomplete | Always debounce with 300ms minimum |
