Qadam Roadmap
проектdocs/Agents/rules/data-locations-and-maps.md

data-locations-and-maps.md

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев


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 документы для инженерной команды
  • Связанные документы:

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:

// 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.

// 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.

// 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.

// 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.

// 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
// 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

// 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

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:

// 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:

@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

IncidentCausePrevention
Map renders at [0, 0] (Atlantic Ocean)Coordinates stored as null, map uses 0 as defaultAlways check for null before rendering map
Wrong pin placementlat/lon swappedUse typed Coordinates object, never positional args
Autocomplete returns results from RussiaMissing countrycodes=uz parameterAlways pass countrycodes=uz to Nominatim
Map crashes on SSRMap library uses window objectAlways lazy-load with ssr: false
Duplicate markers on re-renderState not cleaned between rendersUse key prop or useRef for map instance
Address displayed to publicMissing privacy checkUse displayPublicly flag in all DTOs
Rate limited by NominatimNo debounce on autocompleteAlways debounce with 300ms minimum