575 lines
17 KiB
TypeScript
575 lines
17 KiB
TypeScript
import {
|
|
AnalyticsData,
|
|
AuthTokenDetails,
|
|
PostDetails,
|
|
PostResponse,
|
|
SocialProvider,
|
|
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
|
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
|
import { google } from 'googleapis';
|
|
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
|
|
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
|
import * as process from 'node:process';
|
|
import dayjs from 'dayjs';
|
|
import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
|
|
import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto';
|
|
|
|
const clientAndGmb = () => {
|
|
const client = new google.auth.OAuth2({
|
|
clientId: process.env.GOOGLE_GMB_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID,
|
|
clientSecret:
|
|
process.env.GOOGLE_GMB_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET,
|
|
redirectUri: `${process.env.FRONTEND_URL}/integrations/social/gmb`,
|
|
});
|
|
|
|
const oauth2 = (newClient: OAuth2Client) =>
|
|
google.oauth2({
|
|
version: 'v2',
|
|
auth: newClient,
|
|
});
|
|
|
|
return { client, oauth2 };
|
|
};
|
|
|
|
@Rules(
|
|
'Google My Business posts can have text content and optionally one image. Posts can be updates, events, or offers.'
|
|
)
|
|
export class GmbProvider extends SocialAbstract implements SocialProvider {
|
|
override maxConcurrentJob = 3;
|
|
identifier = 'gmb';
|
|
name = 'Google My Business';
|
|
isBetweenSteps = true;
|
|
scopes = [
|
|
'https://www.googleapis.com/auth/userinfo.profile',
|
|
'https://www.googleapis.com/auth/userinfo.email',
|
|
'https://www.googleapis.com/auth/business.manage',
|
|
];
|
|
editor = 'normal' as const;
|
|
dto = GmbSettingsDto;
|
|
|
|
maxLength() {
|
|
return 1500;
|
|
}
|
|
|
|
override handleErrors(body: string):
|
|
| {
|
|
type: 'refresh-token' | 'bad-body';
|
|
value: string;
|
|
}
|
|
| undefined {
|
|
if (body.includes('UNAUTHENTICATED') || body.includes('invalid_grant')) {
|
|
return {
|
|
type: 'refresh-token',
|
|
value: 'Please re-authenticate your Google My Business account',
|
|
};
|
|
}
|
|
|
|
if (body.includes('Unauthorized')) {
|
|
return {
|
|
type: 'refresh-token',
|
|
value:
|
|
'Token expired or invalid, please reconnect your YouTube account.',
|
|
};
|
|
}
|
|
|
|
if (body.includes('PERMISSION_DENIED')) {
|
|
return {
|
|
type: 'refresh-token',
|
|
value:
|
|
'Permission denied. Please ensure you have access to this business location.',
|
|
};
|
|
}
|
|
|
|
if (body.includes('NOT_FOUND')) {
|
|
return {
|
|
type: 'bad-body',
|
|
value: 'Business location not found. It may have been deleted.',
|
|
};
|
|
}
|
|
|
|
if (body.includes('INVALID_ARGUMENT')) {
|
|
return {
|
|
type: 'bad-body',
|
|
value: 'Invalid post content. Please check your post details.',
|
|
};
|
|
}
|
|
|
|
if (body.includes('RESOURCE_EXHAUSTED')) {
|
|
return {
|
|
type: 'bad-body',
|
|
value: 'Rate limit exceeded. Please try again later.',
|
|
};
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
|
|
const { client, oauth2 } = clientAndGmb();
|
|
client.setCredentials({ refresh_token });
|
|
const { credentials } = await client.refreshAccessToken();
|
|
const user = oauth2(client);
|
|
const expiryDate = new Date(credentials.expiry_date!);
|
|
const unixTimestamp =
|
|
Math.floor(expiryDate.getTime() / 1000) -
|
|
Math.floor(new Date().getTime() / 1000);
|
|
|
|
const { data } = await user.userinfo.get();
|
|
|
|
return {
|
|
accessToken: credentials.access_token!,
|
|
expiresIn: unixTimestamp!,
|
|
refreshToken: credentials.refresh_token || refresh_token,
|
|
id: data.id!,
|
|
name: data.name!,
|
|
picture: data?.picture || '',
|
|
username: '',
|
|
};
|
|
}
|
|
|
|
async generateAuthUrl() {
|
|
const state = makeId(7);
|
|
const { client } = clientAndGmb();
|
|
return {
|
|
url: client.generateAuthUrl({
|
|
access_type: 'offline',
|
|
prompt: 'consent',
|
|
state,
|
|
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/gmb`,
|
|
scope: this.scopes.slice(0),
|
|
}),
|
|
codeVerifier: makeId(11),
|
|
state,
|
|
};
|
|
}
|
|
|
|
async authenticate(params: {
|
|
code: string;
|
|
codeVerifier: string;
|
|
refresh?: string;
|
|
}) {
|
|
const { client, oauth2 } = clientAndGmb();
|
|
const { tokens } = await client.getToken(params.code);
|
|
client.setCredentials(tokens);
|
|
const { scopes } = await client.getTokenInfo(tokens.access_token!);
|
|
this.checkScopes(this.scopes, scopes);
|
|
|
|
const user = oauth2(client);
|
|
const { data } = await user.userinfo.get();
|
|
|
|
const expiryDate = new Date(tokens.expiry_date!);
|
|
const unixTimestamp =
|
|
Math.floor(expiryDate.getTime() / 1000) -
|
|
Math.floor(new Date().getTime() / 1000);
|
|
|
|
return {
|
|
accessToken: tokens.access_token!,
|
|
expiresIn: unixTimestamp,
|
|
refreshToken: tokens.refresh_token!,
|
|
id: data.id!,
|
|
name: data.name!,
|
|
picture: data?.picture || '',
|
|
username: '',
|
|
};
|
|
}
|
|
|
|
async pages(accessToken: string) {
|
|
// Get all accounts first
|
|
const accountsResponse = await fetch(
|
|
'https://mybusinessaccountmanagement.googleapis.com/v1/accounts',
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
}
|
|
);
|
|
const accountsData = await accountsResponse.json();
|
|
|
|
if (!accountsData.accounts || accountsData.accounts.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Get locations for each account
|
|
const allLocations: Array<{
|
|
id: string;
|
|
name: string;
|
|
picture: { data: { url: string } };
|
|
accountName: string;
|
|
locationName: string;
|
|
}> = [];
|
|
|
|
for (const account of accountsData.accounts) {
|
|
const accountName = account.name; // format: accounts/{accountId}
|
|
|
|
try {
|
|
const locationsResponse = await fetch(
|
|
`https://mybusinessbusinessinformation.googleapis.com/v1/${accountName}/locations?readMask=name,title,storefrontAddress,metadata`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
}
|
|
);
|
|
const locationsData = await locationsResponse.json();
|
|
|
|
if (locationsData.locations) {
|
|
for (const location of locationsData.locations) {
|
|
// location.name is in format: locations/{locationId}
|
|
// We need the full path: accounts/{accountId}/locations/{locationId}
|
|
const locationId = location.name.replace('locations/', '');
|
|
const fullResourceName = `${accountName}/locations/${locationId}`;
|
|
|
|
// Get profile photo if available
|
|
let photoUrl = '';
|
|
try {
|
|
const mediaResponse = await fetch(
|
|
`https://mybusinessbusinessinformation.googleapis.com/v1/${location.name}/media`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
}
|
|
);
|
|
const mediaData = await mediaResponse.json();
|
|
if (mediaData.mediaItems && mediaData.mediaItems.length > 0) {
|
|
const profilePhoto = mediaData.mediaItems.find(
|
|
(m: any) =>
|
|
m.mediaFormat === 'PHOTO' &&
|
|
m.locationAssociation?.category === 'PROFILE'
|
|
);
|
|
if (profilePhoto?.googleUrl) {
|
|
photoUrl = profilePhoto.googleUrl;
|
|
} else if (mediaData.mediaItems[0]?.googleUrl) {
|
|
photoUrl = mediaData.mediaItems[0].googleUrl;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore media fetch errors
|
|
}
|
|
|
|
allLocations.push({
|
|
// id is the full resource path for the v4 API: accounts/{accountId}/locations/{locationId}
|
|
id: fullResourceName,
|
|
name: location.title || 'Unnamed Location',
|
|
picture: { data: { url: photoUrl } },
|
|
accountName: accountName,
|
|
locationName: location.name,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Continue with other accounts if one fails
|
|
console.error(
|
|
`Failed to fetch locations for account ${accountName}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
return allLocations;
|
|
}
|
|
|
|
async fetchPageInformation(
|
|
accessToken: string,
|
|
data: { id: string; accountName: string; locationName: string }
|
|
) {
|
|
// data.id is the full resource path: accounts/{accountId}/locations/{locationId}
|
|
// data.locationName is the v1 API format: locations/{locationId}
|
|
// Fetch location details using the v1 API format
|
|
const locationResponse = await fetch(
|
|
`https://mybusinessbusinessinformation.googleapis.com/v1/${data.locationName}?readMask=name,title,storefrontAddress,metadata`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
}
|
|
);
|
|
const locationData = await locationResponse.json();
|
|
|
|
// Try to get profile photo
|
|
let photoUrl = '';
|
|
try {
|
|
const mediaResponse = await fetch(
|
|
`https://mybusinessbusinessinformation.googleapis.com/v1/${data.locationName}/media`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
}
|
|
);
|
|
const mediaData = await mediaResponse.json();
|
|
if (mediaData.mediaItems && mediaData.mediaItems.length > 0) {
|
|
const profilePhoto = mediaData.mediaItems.find(
|
|
(m: any) =>
|
|
m.mediaFormat === 'PHOTO' &&
|
|
m.locationAssociation?.category === 'PROFILE'
|
|
);
|
|
if (profilePhoto?.googleUrl) {
|
|
photoUrl = profilePhoto.googleUrl;
|
|
} else if (mediaData.mediaItems[0]?.googleUrl) {
|
|
photoUrl = mediaData.mediaItems[0].googleUrl;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore media fetch errors
|
|
}
|
|
|
|
return {
|
|
// Return the full resource path as id (for v4 Local Posts API)
|
|
id: data.id,
|
|
name: locationData.title || 'Unnamed Location',
|
|
access_token: accessToken,
|
|
picture: photoUrl,
|
|
username: '',
|
|
};
|
|
}
|
|
|
|
async reConnect(
|
|
id: string,
|
|
requiredId: string,
|
|
accessToken: string
|
|
): Promise<Omit<AuthTokenDetails, 'refreshToken' | 'expiresIn'>> {
|
|
const pages = await this.pages(accessToken);
|
|
const findPage = pages.find((p) => p.id === requiredId);
|
|
|
|
if (!findPage) {
|
|
throw new Error('Location not found');
|
|
}
|
|
|
|
const information = await this.fetchPageInformation(accessToken, {
|
|
id: requiredId,
|
|
accountName: findPage.accountName,
|
|
locationName: findPage.locationName,
|
|
});
|
|
|
|
return {
|
|
id: information.id,
|
|
name: information.name,
|
|
accessToken: information.access_token,
|
|
picture: information.picture,
|
|
username: information.username,
|
|
};
|
|
}
|
|
|
|
async post(
|
|
id: string,
|
|
accessToken: string,
|
|
postDetails: PostDetails<GmbSettingsDto>[]
|
|
): Promise<PostResponse[]> {
|
|
const [firstPost] = postDetails;
|
|
const { settings } = firstPost;
|
|
|
|
// Build the local post request body
|
|
const postBody: any = {
|
|
languageCode: 'en',
|
|
summary: firstPost.message,
|
|
topicType: settings?.topicType || 'STANDARD',
|
|
};
|
|
|
|
// Add call to action if provided (and not NONE)
|
|
if (
|
|
settings?.callToActionType &&
|
|
settings.callToActionType !== 'NONE' &&
|
|
settings?.callToActionUrl
|
|
) {
|
|
postBody.callToAction = {
|
|
actionType: settings.callToActionType,
|
|
url: settings.callToActionUrl,
|
|
};
|
|
}
|
|
|
|
// Add media if provided
|
|
if (firstPost.media && firstPost.media.length > 0) {
|
|
const mediaItem = firstPost.media[0];
|
|
postBody.media = [
|
|
{
|
|
mediaFormat: mediaItem.type === 'video' ? 'VIDEO' : 'PHOTO',
|
|
sourceUrl: mediaItem.path,
|
|
},
|
|
];
|
|
}
|
|
|
|
// Add event details if it's an event post
|
|
if (settings?.topicType === 'EVENT' && settings?.eventTitle) {
|
|
postBody.event = {
|
|
title: settings.eventTitle,
|
|
schedule: {
|
|
startDate: this.formatDate(settings.eventStartDate),
|
|
endDate: this.formatDate(settings.eventEndDate),
|
|
...(settings.eventStartTime && {
|
|
startTime: this.formatTime(settings.eventStartTime),
|
|
}),
|
|
...(settings.eventEndTime && {
|
|
endTime: this.formatTime(settings.eventEndTime),
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
// Add offer details if it's an offer post
|
|
if (settings?.topicType === 'OFFER') {
|
|
postBody.offer = {
|
|
couponCode: settings?.offerCouponCode || undefined,
|
|
redeemOnlineUrl: settings?.offerRedeemUrl || undefined,
|
|
termsConditions: settings?.offerTerms || undefined,
|
|
};
|
|
}
|
|
|
|
// Create the local post
|
|
const response = await this.fetch(
|
|
`https://mybusiness.googleapis.com/v4/${id}/localPosts`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(postBody),
|
|
},
|
|
'create local post'
|
|
);
|
|
|
|
const postData = await response.json();
|
|
|
|
// Extract the post ID and construct the URL
|
|
const postId = postData.name || '';
|
|
const locationId = id.split('/').pop();
|
|
|
|
// GMB posts don't have direct URLs, but we can link to the business profile
|
|
const releaseURL = `https://business.google.com/locations/${locationId}`;
|
|
|
|
return [
|
|
{
|
|
id: firstPost.id,
|
|
postId: postId,
|
|
releaseURL: releaseURL,
|
|
status: 'success',
|
|
},
|
|
];
|
|
}
|
|
|
|
private formatDate(dateString?: string): any {
|
|
if (!dateString) {
|
|
return {
|
|
year: dayjs().year(),
|
|
month: dayjs().month() + 1,
|
|
day: dayjs().date(),
|
|
};
|
|
}
|
|
const date = dayjs(dateString);
|
|
return {
|
|
year: date.year(),
|
|
month: date.month() + 1,
|
|
day: date.date(),
|
|
};
|
|
}
|
|
|
|
private formatTime(timeString?: string): any {
|
|
if (!timeString) {
|
|
return undefined;
|
|
}
|
|
const [hours, minutes] = timeString.split(':').map(Number);
|
|
return {
|
|
hours: hours || 0,
|
|
minutes: minutes || 0,
|
|
seconds: 0,
|
|
nanos: 0,
|
|
};
|
|
}
|
|
|
|
async analytics(
|
|
id: string,
|
|
accessToken: string,
|
|
date: number
|
|
): Promise<AnalyticsData[]> {
|
|
try {
|
|
const endDate = dayjs().format('YYYY-MM-DD');
|
|
const startDate = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
|
|
|
|
// id is in format: accounts/{accountId}/locations/{locationId}
|
|
// Business Profile Performance API expects: locations/{locationId}
|
|
const locationId = id.split('/locations/')[1];
|
|
const locationPath = `locations/${locationId}`;
|
|
|
|
// Use the Business Profile Performance API
|
|
const response = await fetch(
|
|
`https://businessprofileperformance.googleapis.com/v1/${locationPath}:fetchMultiDailyMetricsTimeSeries?dailyMetrics=WEBSITE_CLICKS&dailyMetrics=CALL_CLICKS&dailyMetrics=BUSINESS_DIRECTION_REQUESTS&dailyMetrics=BUSINESS_IMPRESSIONS_DESKTOP_MAPS&dailyMetrics=BUSINESS_IMPRESSIONS_MOBILE_MAPS&dailyRange.startDate.year=${dayjs(
|
|
startDate
|
|
).year()}&dailyRange.startDate.month=${
|
|
dayjs(startDate).month() + 1
|
|
}&dailyRange.startDate.day=${dayjs(
|
|
startDate
|
|
).date()}&dailyRange.endDate.year=${dayjs(
|
|
endDate
|
|
).year()}&dailyRange.endDate.month=${
|
|
dayjs(endDate).month() + 1
|
|
}&dailyRange.endDate.day=${dayjs(endDate).date()}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
const data = await response.json();
|
|
|
|
// Response structure: { multiDailyMetricTimeSeries: [{ dailyMetricTimeSeries: [...] }] }
|
|
const dailyMetricTimeSeries =
|
|
data.multiDailyMetricTimeSeries?.[0]?.dailyMetricTimeSeries;
|
|
|
|
if (!dailyMetricTimeSeries || dailyMetricTimeSeries.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const metricLabels: { [key: string]: string } = {
|
|
WEBSITE_CLICKS: 'Website Clicks',
|
|
CALL_CLICKS: 'Phone Calls',
|
|
BUSINESS_DIRECTION_REQUESTS: 'Direction Requests',
|
|
BUSINESS_IMPRESSIONS_DESKTOP_MAPS: 'Desktop Map Views',
|
|
BUSINESS_IMPRESSIONS_MOBILE_MAPS: 'Mobile Map Views',
|
|
};
|
|
|
|
const analytics: AnalyticsData[] = [];
|
|
|
|
for (const series of dailyMetricTimeSeries) {
|
|
const metricName = series.dailyMetric;
|
|
const label = metricLabels[metricName] || metricName;
|
|
|
|
const datedValues = series.timeSeries?.datedValues || [];
|
|
|
|
const dataPoints = datedValues.map((dv: any) => ({
|
|
total: parseInt(dv.value || '0', 10),
|
|
date: `${dv.date.year}-${String(dv.date.month).padStart(
|
|
2,
|
|
'0'
|
|
)}-${String(dv.date.day).padStart(2, '0')}`,
|
|
}));
|
|
|
|
if (dataPoints.length > 0) {
|
|
analytics.push({
|
|
label,
|
|
percentageChange: 0,
|
|
data: dataPoints,
|
|
});
|
|
}
|
|
}
|
|
|
|
return analytics;
|
|
} catch (error) {
|
|
console.error('Error fetching GMB analytics:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async postAnalytics(
|
|
integrationId: string,
|
|
accessToken: string,
|
|
postId: string,
|
|
date: number
|
|
): Promise<AnalyticsData[]> {
|
|
// Google My Business local posts don't have detailed individual post analytics
|
|
// The API focuses on location-level metrics rather than post-level metrics
|
|
return [];
|
|
}
|
|
}
|