← 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();