Vizzuality/landgriffon

View on GitHub
api/src/modules/geo-coding/strategies/base-strategy.ts

Summary

Maintainability
A
0 mins
Test Coverage
F
59%
import { Inject, Injectable } from '@nestjs/common';
import { AdminRegionsService } from 'modules/admin-regions/admin-regions.service';
import { GeoRegionsService } from 'modules/geo-regions/geo-regions.service';
import { SourcingLocationsService } from 'modules/sourcing-locations/sourcing-locations.service';
import { SourcingData } from 'modules/import-data/sourcing-data/dto-processor.service';
import {
  GeocodeResponse,
  Geocoder,
  GeocoderInterface,
} from 'modules/geo-coding/geocoders/geocoder.interface';
import { AddressComponent } from '@googlemaps/google-maps-services-js';
import { GeocodeResult } from '@googlemaps/google-maps-services-js/dist/common';
import { SourcingLocation } from 'modules/sourcing-locations/sourcing-location.entity';
import { GeoCodingError } from 'modules/geo-coding/errors/geo-coding.error';

/**
 * @note: Landgriffon Geocoding strategy doc:
 * https://docs.google.com/document/d/1wjRa6wEnWmDpu0mc54EAwSXwuVtPGhAREtZBWQZID5c/edit#
 */

@Injectable()
export abstract class BaseStrategy {
  constructor(
    @Inject(Geocoder) protected readonly geocoder: GeocoderInterface,
    protected readonly adminRegionService: AdminRegionsService,
    protected readonly geoRegionService: GeoRegionsService,
    protected readonly sourcingLocationService: SourcingLocationsService,
  ) {}

  async geoCodeByCountry(country: string): Promise<GeocodeResponse> {
    return this.geocoder.geocode({
      address: `country ${country}`,
    });
  }

  /**
   ** @description Geocodes address and retrieves most accurate response.
   * Geocoding results have address_components array which contains all parent
   * geographical components, so in order to take most accurate result it takes the
   * one with most elements.
   */
  async geoCodeByAddress(
    locationAddress: string,
    locationCountry: string,
  ): Promise<{
    data: GeocodeResponse;
    warning: string | undefined;
  }> {
    let warning: string | undefined;
    const geocodeResponseData: GeocodeResponse = await this.geocoder.geocode({
      address: `${locationAddress}, ${locationCountry}`,
    });
    this.validateGeoCodeResponse(
      geocodeResponseData,
      locationAddress,
      locationCountry,
    );

    if (geocodeResponseData.results.length > 1) {
      // Take the most accurate location within the response, and add a warning
      geocodeResponseData.results = [
        geocodeResponseData.results.reduce(
          (prev: GeocodeResult, current: GeocodeResult) => {
            return prev.address_components.length >
              current.address_components.length
              ? prev
              : current;
          },
        ),
      ];
      warning = `${locationAddress},${locationCountry} is ambiguous, taking most accurate interpretation.`;
    }

    return { data: geocodeResponseData, warning };
  }

  isAddressACountry(locationTypes: string[]): boolean {
    return locationTypes.includes('country');
  }

  isAddressAdminLevel1(locationTypes: string[]): boolean {
    return locationTypes.includes('administrative_area_level_1');
  }

  isAddressAdminLevel2(locationTypes: string[]): boolean {
    return locationTypes.includes('administrative_area_level_2');
  }

  getIsoA2Code(geoCodedResponse: GeocodeResponse): string {
    // TODO: remove this google type dependency.
    const country: AddressComponent | undefined =
      geoCodedResponse.results[0].address_components.find((address: any) =>
        address.types.includes('country'),
      );
    if (country) return country.short_name;
    throw new Error(`Could not find ISO2 code`);
  }

  getCountryNameFromGeocodeResult(geocodeResult: GeocodeResult): string {
    const country: AddressComponent | undefined =
      geocodeResult.address_components.find((address: any) =>
        address.types.includes('country'),
      );
    if (country) return country.long_name;
    throw new Error(`Could not get country`);
  }

  /**
   ** @description Validate Geocode response.
   * When address is outside provided country, Geocoding will return
   * several results, one for country and one for address. In case of
   * several countries in result set, raise an error.
   */
  validateGeoCodeResponse(
    geoCodedResponse: GeocodeResponse,
    address: string,
    country: string,
  ): void {
    const countrySet: Set<string> = new Set();
    geoCodedResponse.results.forEach((result: GeocodeResult) => {
      countrySet.add(this.getCountryNameFromGeocodeResult(result));
    });
    if (countrySet.size > 1) {
      throw new GeoCodingError(
        `Address outside provided country: ${address}, ${country}`,
      );
    }
  }

  hasBothAddressAndCoordinates(sourcingData: SourcingData): boolean {
    return !!(
      sourcingData.locationAddressInput && sourcingData.locationLatitude
    );
  }

  async findExistingSourcingLocationByGeoRegionId(
    geoRegionId: string,
  ): Promise<SourcingLocation | null> {
    return this.sourcingLocationService.findByGeoRegionId(geoRegionId);
  }
}