← back to Jill Website

src/services/googleReviewsService.ts

265 lines

import axios from 'axios';
import dotenv from 'dotenv';

dotenv.config();

export interface ReviewInfo {
  reviewLink: string;
  rating?: number;
  totalReviews?: number;
  placeId: string;
}

export interface GoogleReviewsConfig {
  apiKey: string;
}

/**
 * Google Reviews Service
 * Provides integration with Google Places API to retrieve review information
 * and generate review links for businesses
 */
export class GoogleReviewsService {
  private apiKey: string;
  private baseUrl: string = 'https://maps.googleapis.com/maps/api';
  private rateLimitDelay: number = 100; // milliseconds between requests
  private lastRequestTime: number = 0;

  constructor(config?: GoogleReviewsConfig) {
    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;
  }

  /**
   * Apply rate limiting to prevent exceeding API quotas
   */
  private async applyRateLimit(): Promise<void> {
    const now = Date.now();
    const timeSinceLastRequest = now - this.lastRequestTime;

    if (timeSinceLastRequest < this.rateLimitDelay) {
      const delay = this.rateLimitDelay - timeSinceLastRequest;
      await new Promise(resolve => setTimeout(resolve, delay));
    }

    this.lastRequestTime = Date.now();
  }

  /**
   * Generate a Google Reviews link for a business
   * @param placeId - Google Place ID
   * @returns Review link URL
   */
  generateReviewLink(placeId: string): string {
    if (!placeId || placeId.trim().length === 0) {
      throw new Error('Place ID is required to generate review link');
    }

    // Generate link to Google Maps reviews page
    return `https://search.google.com/local/reviews?placeid=${placeId}`;
  }

  /**
   * Get review information for a business by Place ID
   * @param placeId - Google Place ID
   * @returns Review information including rating and review link
   */
  async getReviewInfo(placeId: string): Promise<ReviewInfo | null> {
    if (!this.isConfigured()) {
      throw new Error('Google Maps API key is not configured');
    }

    if (!placeId || placeId.trim().length === 0) {
      throw new Error('Place ID is required');
    }

    try {
      // Apply rate limiting
      await this.applyRateLimit();

      const url = `${this.baseUrl}/place/details/json`;
      const response = await axios.get(url, {
        params: {
          place_id: placeId,
          fields: 'rating,user_ratings_total,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;

      return {
        reviewLink: this.generateReviewLink(placeId),
        rating: result.rating,
        totalReviews: result.user_ratings_total,
        placeId: placeId
      };
    } catch (error) {
      console.error('Error getting review info:', error);
      throw this.handleError(error);
    }
  }

  /**
   * Search for a business and get its review information
   * @param businessName - Name of the business
   * @param location - Optional location to narrow search (e.g., "Nosara, Costa Rica")
   * @returns Review information if business is found
   */
  async searchAndGetReviewInfo(businessName: string, location?: string): Promise<ReviewInfo | null> {
    if (!this.isConfigured()) {
      throw new Error('Google Maps API key is not configured');
    }

    if (!businessName || businessName.trim().length === 0) {
      throw new Error('Business name is required');
    }

    try {
      // Apply rate limiting
      await this.applyRateLimit();

      // Construct search query
      let query = businessName;
      if (location) {
        query = `${businessName}, ${location}`;
      }

      // Search for the business
      const searchUrl = `${this.baseUrl}/place/textsearch/json`;
      const searchResponse = await axios.get(searchUrl, {
        params: {
          query: query,
          key: this.apiKey
        },
        timeout: 10000
      });

      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: ${query}`);
        return null;
      }

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

      return {
        reviewLink: this.generateReviewLink(placeId),
        rating: rating,
        totalReviews: totalReviews,
        placeId: placeId
      };
    } catch (error) {
      console.error('Error searching for business reviews:', error);
      throw this.handleError(error);
    }
  }

  /**
   * Batch get review information for multiple Place IDs
   * @param placeIds - Array of Google Place IDs
   * @returns Map of Place ID to review information
   */
  async batchGetReviewInfo(placeIds: string[]): Promise<Map<string, ReviewInfo | null>> {
    const results = new Map<string, ReviewInfo | null>();

    for (const placeId of placeIds) {
      try {
        const reviewInfo = await this.getReviewInfo(placeId);
        results.set(placeId, reviewInfo);
      } catch (error) {
        console.error(`Error getting review info for place ${placeId}:`, error);
        results.set(placeId, null);
      }
    }

    return results;
  }

  /**
   * 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 place details request to verify the key works
      // Using a well-known place ID (Google headquarters)
      const testPlaceId = 'ChIJj61dQgK6j4AR4GeTYWZsKWw';
      const url = `${this.baseUrl}/place/details/json`;
      const response = await axios.get(url, {
        params: {
          place_id: testPlaceId,
          fields: 'place_id',
          key: this.apiKey
        },
        timeout: 5000
      });

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

// Export singleton instance
export const googleReviewsService = new GoogleReviewsService();