Files
maps-saas/apps/api/src/geocoding/geocoding.controller.ts
T
2026-04-14 03:03:35 +03:00

157 lines
5.8 KiB
TypeScript

import { Controller, Get, Post, Delete, Body, Query, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { GeocodingService } from './geocoding.service';
import { AdminBoundariesService } from './admin-boundaries.service';
import { JordanResearchService } from './jordan-research.service';
import { AdministrativeLinkingService } from './administrative-linking.service';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@ApiTags('geocoding')
@Controller('geocoding')
export class GeocodingController {
constructor(
private readonly geocodingService: GeocodingService,
private readonly adminBoundariesService: AdminBoundariesService,
private readonly jordanResearchService: JordanResearchService,
private readonly adminLinkingService: AdministrativeLinkingService,
) {}
@Get('search')
@ApiOperation({ summary: 'Search for locations (Forward Geocoding)' })
@ApiQuery({ name: 'q', required: true })
@ApiQuery({ name: 'lat', required: false, type: Number })
@ApiQuery({ name: 'lng', required: false, type: Number })
@ApiQuery({ name: 'radius', required: false, type: Number })
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
async search(
@Query('q') query: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('radius') radius?: string,
@Query('country') country?: string,
) {
// NestJS @Query() always receives strings — must parse explicitly for numeric types
const parsedLat = lat !== undefined ? parseFloat(lat) : Number.NaN;
const parsedLng = lng !== undefined ? parseFloat(lng) : Number.NaN;
const parsedRadius = radius !== undefined ? parseFloat(radius) : 20000;
// Guard against malformed float values (NaN breaks PostGIS)
const safeLat = !isNaN(parsedLat) ? parsedLat : undefined;
const safeLng = !isNaN(parsedLng) ? parsedLng : undefined;
return this.geocodingService.searchPlaces(query, safeLat, safeLng, parsedRadius, country);
}
@Get('reverse')
@ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' })
@ApiQuery({ name: 'lat', required: true })
@ApiQuery({ name: 'lng', required: true })
async reverse(
@Query('lat') lat: string,
@Query('lng') lng: string,
) {
const parsedLat = parseFloat(lat);
const parsedLng = parseFloat(lng);
if (isNaN(parsedLat) || isNaN(parsedLng)) {
throw new HttpException('Invalid lat/lng values', HttpStatus.BAD_REQUEST);
}
return this.geocodingService.reverseGeocode(parsedLat, parsedLng);
}
@Post('places')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Add a new location (User Submitted)' })
async addPlace(@Body() placeData: any) {
return this.geocodingService.addPlace(placeData);
}
@Delete('places')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Delete a place by name or ID' })
@ApiQuery({ name: 'name', required: false })
@ApiQuery({ name: 'id', required: false, type: Number })
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] })
async deletePlace(
@Query('country') country: string,
@Query('name') name?: string,
@Query('id') id?: string,
) {
if (id) {
return this.geocodingService.deletePlaceById(Number(id), country);
}
if (name) {
return this.geocodingService.deletePlacesByName(name, country);
}
throw new HttpException('Name or ID required', HttpStatus.BAD_REQUEST);
}
@Post('upsert-place')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Add or Update a location (Automated Scraper)' })
async upsertPlace(@Body() placeData: any) {
return this.geocodingService.upsertPlace(placeData);
}
@Post('upsert-batch')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Add or Update multiple locations in bulk' })
async upsertBatch(@Body() body: { places: any[] }) {
return this.geocodingService.upsertBatch(body.places);
}
@Get('places')
@ApiOperation({ summary: 'Get recent user submitted places' })
async getPlaces(@Query('limit') limit?: string) {
return this.geocodingService.getRecentPlaces(limit ? parseInt(limit, 10) : 50);
}
@Get('geojson')
@ApiOperation({ summary: 'Get all user submitted places as GeoJSON for Map Style' })
async getGeoJSON() {
return this.geocodingService.getAllPlacesGeoJSON();
}
@Post('import-boundaries')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Import administrative boundaries from a local GeoJSON file on the server' })
@ApiQuery({ name: 'country', required: true })
@ApiQuery({ name: 'filePath', required: true })
async importBoundaries(
@Query('country') country: string,
@Query('filePath') filePath: string,
) {
return this.adminBoundariesService.importFromFile(country, filePath);
}
@Get('research/zarqa')
@ApiOperation({ summary: 'Generate a research report for Zarqa, Jordan (Sample Data)' })
async zarqaResearch() {
return this.jordanResearchService.generateZarqaReport();
}
@Post('admin/sync-neighborhoods')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
@ApiQuery({ name: 'bbox', required: false })
async syncNeighborhoods(@Query('bbox') bbox?: string) {
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox);
}
@Post('admin/generate-voronoi')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
async generateVoronoi() {
return this.adminLinkingService.generateVoronoiNeighborhoods();
}
@Post('admin/link-places')
@UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Link places to administrative hierarchy' })
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria'] })
async linkPlaces(@Query('country') country: 'jordan' | 'syria') {
return this.adminLinkingService.linkPlaces(country);
}
}