← back to Jill Website

src/services/googleMapsService.ts

267 lines

import axios from 'axios';
import dotenv from 'dotenv';
import { googleReviewsService } from './googleReviewsService';

dotenv.config();

export interface GoogleMapsConfig {
  apiKey: string;
}

export interface PlaceDetails {
  name: string;
  address: string;
  phone?: string;
  website?: string;
  rating?: number;
  reviewLink?: string;
  placeId: string;
}

export interface MapLinkOptions {
  address?: string;
  placeId?: string;
  businessName?: string;
}

export interface GoogleMapsError {
  code: string;
  message: string;
  status?: string;
}

/**
 * Google Maps Service
 * Provides integration with Google Maps API for generating map links
 * and retrieving business information
 */
export class GoogleMapsService {
  private apiKey: string;
  private baseUrl: string = 'https://maps.googleapis.com/maps/api';

  constructor(config?: GoogleMapsConfig) {
    this.apiKey = config?.apiKey || process.env.GOOGLE_MAPS_API_KEY || '';

    if (!this.apiKey) {
      console.warn('Warning: Google Maps API key is not configured. Set GOOGLE_MAPS_API_KEY in environment variables.');
    }
  }

  /**
   * Verify that the API key is configured
   */
  isConfigured(): boolean {
    return this.apiKey.length > 0;
  }

  /**
   * Generate a Google Maps link from address or place information
   * @param options - Map link options containing address, placeId, or business name
   * @returns Google Maps URL
   */
  generateMapLink(options: MapLinkOptions): string {
    try {
      if (!options.address && !options.placeId && !options.businessName) {
        throw new Error('At least one of address, placeId, or businessName must be provided');
      }

      // If we have a placeId, use the direct place link
      if (options.placeId) {
        return `https://www.google.com/maps/place/?q=place_id:${options.placeId}`;
      }

      // Otherwise, construct a search query
      let query = '';
      if (options.businessName && options.address) {
        query = `${options.businessName}, ${options.address}`;
      } else if (options.businessName) {
        query = options.businessName;
      } else if (options.address) {
        query = options.address;
      }

      // URL encode the query
      const encodedQuery = encodeURIComponent(query);
      return `https://www.google.com/maps/search/?api=1&query=${encodedQuery}`;
    } catch (error) {
      console.error('Error generating map link:', error);
      throw this.handleError(error);
    }
  }

  /**
   * Search for a place and get its details using Google Places API
   * @param searchQuery - Business name and/or address to search for
   * @returns Place details including placeId, address, phone, website
   */
  async searchPlace(searchQuery: string): Promise<PlaceDetails | null> {
    if (!this.isConfigured()) {
      throw new Error('Google Maps API key is not configured');
    }

    try {
      // First, find the place using Text Search
      const searchUrl = `${this.baseUrl}/place/textsearch/json`;
      const searchResponse = await axios.get(searchUrl, {
        params: {
          query: searchQuery,
          key: this.apiKey
        },
        timeout: 10000 // 10 second timeout
      });

      if (searchResponse.data.status !== 'OK' && searchResponse.data.status !== 'ZERO_RESULTS') {
        throw new Error(`Google Maps API error: ${searchResponse.data.status} - ${searchResponse.data.error_message || 'Unknown error'}`);
      }

      if (searchResponse.data.results.length === 0) {
        console.log(`No results found for query: ${searchQuery}`);
        return null;
      }

      // Get the first result's place_id
      const placeId = searchResponse.data.results[0].place_id;

      // Now get detailed information about the place
      const detailsUrl = `${this.baseUrl}/place/details/json`;
      const detailsResponse = await axios.get(detailsUrl, {
        params: {
          place_id: placeId,
          fields: 'name,formatted_address,formatted_phone_number,website,rating,place_id',
          key: this.apiKey
        },
        timeout: 10000
      });

      if (detailsResponse.data.status !== 'OK') {
        throw new Error(`Google Maps API error: ${detailsResponse.data.status} - ${detailsResponse.data.error_message || 'Unknown error'}`);
      }

      const result = detailsResponse.data.result;

      // Generate review link
      const reviewLink = googleReviewsService.generateReviewLink(result.place_id);

      return {
        name: result.name,
        address: result.formatted_address,
        phone: result.formatted_phone_number,
        website: result.website,
        rating: result.rating,
        reviewLink: reviewLink,
        placeId: result.place_id
      };
    } catch (error) {
      console.error('Error searching for place:', error);
      throw this.handleError(error);
    }
  }

  /**
   * Get place details by Place ID
   * @param placeId - Google Place ID
   * @returns Place details
   */
  async getPlaceById(placeId: string): Promise<PlaceDetails | null> {
    if (!this.isConfigured()) {
      throw new Error('Google Maps API key is not configured');
    }

    try {
      const url = `${this.baseUrl}/place/details/json`;
      const response = await axios.get(url, {
        params: {
          place_id: placeId,
          fields: 'name,formatted_address,formatted_phone_number,website,rating,place_id',
          key: this.apiKey
        },
        timeout: 10000
      });

      if (response.data.status !== 'OK' && response.data.status !== 'NOT_FOUND') {
        throw new Error(`Google Maps API error: ${response.data.status} - ${response.data.error_message || 'Unknown error'}`);
      }

      if (response.data.status === 'NOT_FOUND') {
        console.log(`Place not found with ID: ${placeId}`);
        return null;
      }

      const result = response.data.result;

      // Generate review link
      const reviewLink = googleReviewsService.generateReviewLink(result.place_id);

      return {
        name: result.name,
        address: result.formatted_address,
        phone: result.formatted_phone_number,
        website: result.website,
        rating: result.rating,
        reviewLink: reviewLink,
        placeId: result.place_id
      };
    } catch (error) {
      console.error('Error getting place by ID:', error);
      throw this.handleError(error);
    }
  }

  /**
   * Handle and format errors consistently
   */
  private handleError(error: unknown): Error {
    if (axios.isAxiosError(error)) {
      if (error.response) {
        // API responded with error
        const status = error.response.data?.status || 'UNKNOWN';
        const message = error.response.data?.error_message || error.message;
        return new Error(`Google Maps API error: ${status} - ${message}`);
      } else if (error.request) {
        // Request made but no response
        return new Error('Google Maps API request failed: No response received');
      } else {
        // Error in request setup
        return new Error(`Google Maps API request error: ${error.message}`);
      }
    }

    if (error instanceof Error) {
      return error;
    }

    return new Error('Unknown error occurred while accessing Google Maps API');
  }

  /**
   * Verify API connection and key validity
   * @returns true if API is accessible and key is valid
   */
  async verifyConnection(): Promise<boolean> {
    if (!this.isConfigured()) {
      console.error('Google Maps API key is not configured');
      return false;
    }

    try {
      // Try a simple geocode request to verify the key works
      const url = `${this.baseUrl}/geocode/json`;
      const response = await axios.get(url, {
        params: {
          address: 'Nosara, Costa Rica',
          key: this.apiKey
        },
        timeout: 5000
      });

      return response.data.status === 'OK' || response.data.status === 'ZERO_RESULTS';
    } catch (error) {
      console.error('Google Maps API connection verification failed:', error);
      return false;
    }
  }
}

// Export singleton instance
export const googleMapsService = new GoogleMapsService();