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 ONLINEitems must NOT have coordinates or address fields populatedOFFLINE_ADDRESSitems MUST have valid coordinates and addressMOBILE_TUTORitems 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
Floatin Prisma /numberin 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 returnslat,lonbut 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-Agentheader with app name and contact email - Always set
countrycodes=uzto 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,rufor 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
centerprop — 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:
latitudeandlongitudeareFloat, neverStringcityis denormalized from address for fast filtering (indexed)- Multiple branches stored in separate
ItemBranchtable, not JSON array - Index on
[city, locationType]for catalog filtering locationIdreferences 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
| 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 |