Merge pull request #1082 from gitroomhq/feat/gmb

Add Google My Business
This commit is contained in:
Nevo David 2025-11-27 20:58:01 +07:00 committed by GitHub
commit fa1b8b83fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1088 additions and 67 deletions

View File

@ -560,6 +560,15 @@ export class IntegrationsController {
return this._integrationService.saveLinkedin(org.id, id, body.page);
}
@Post('/gmb/:id')
async saveGmb(
@Param('id') id: string,
@Body() body: { id: string; accountName: string; locationName: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveGmb(org.id, id, body);
}
@Post('/enable')
enableChannel(
@GetOrgFromRequest() org: Organization,

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -17,6 +17,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
import { web3List } from '@gitroom/frontend/components/launches/web3/web3.list';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { ModalWrapperComponent } from '@gitroom/frontend/components/new-launch/modal.wrapper.component';
import clsx from 'clsx';
const resolver = classValidatorResolver(ApiKeyDto);
export const useAddProvider = (update?: () => void) => {
const modal = useModals();
@ -457,7 +458,7 @@ export const AddProviderComponent: FC<{
<img src={`/icons/platforms/youtube.svg`} />
) : (
<img
className="w-[32px] h-[32px] rounded-full"
className={clsx("w-[32px] h-[32px]", item.identifier !== 'google_my_business' && 'rounded-full')}
src={`/icons/platforms/${item.identifier}.png`}
/>
)}

View File

@ -1,4 +1,4 @@
import React, { FC, useCallback, useMemo } from 'react';
import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
@ -7,6 +7,7 @@ import useSWR, { useSWRConfig } from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { continueProviderList } from '@gitroom/frontend/components/new-launch/providers/continue-provider/list';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
export const Null: FC<{
closeModal: () => void;
existingId: string[];
@ -46,74 +47,73 @@ export const ContinueProvider: FC = () => {
continueProviderList[added as keyof typeof continueProviderList] || Null
);
}, [added]);
if (!added || !continueId || !integrations) {
return null;
}
return (
<div
className="fixed start-0 top-0 w-full h-full bg-primary/40 z-[499]"
onClick={closeModal}
>
<div
className="w-[100%] max-w-[674px] absolute start-[50%] top-[65px] bg-customColor3 z-[500] -translate-x-[50%] text-textColor p-[16px] !pt-0 border border-customColor6 min-h-[300px]"
onClick={(e) => e.stopPropagation()}
>
<div className="w-full h-full relative">
<TopTitle title="Configure Provider" />
<button
onClick={closeModal}
className="outline-none absolute end-0 top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
<div className="pt-[16px] max-h-[600px] overflow-hidden overflow-y-auto">
<IntegrationContext.Provider
value={{
date: newDayjs(),
value: [],
allIntegrations: [],
integration: {
editor: 'normal',
additionalSettings: '',
display: '',
time: [
{
time: 0,
},
],
id: continueId,
type: '',
name: '',
picture: '',
inBetweenSteps: true,
changeNickName: false,
changeProfilePicture: false,
identifier: added,
},
}}
>
<Provider
closeModal={closeModal}
existingId={integrations.map((p: any) => p.internalId)}
/>
</IntegrationContext.Provider>
</div>
</div>
</div>
</div>
<ContinueModal
added={added}
continueId={continueId}
integrations={integrations.map((p: any) => p.internalId)}
provider={Provider}
/>
);
};
const ModalContent: FC<{
continueId: string;
added: any;
provider: any;
closeModal: () => void;
integrations: string[];
}> = ({ continueId, added, provider: Provider, closeModal, integrations }) => {
return (
<IntegrationContext.Provider
value={{
date: newDayjs(),
value: [],
allIntegrations: [],
integration: {
editor: 'normal',
additionalSettings: '',
display: '',
time: [
{
time: 0,
},
],
id: continueId,
type: '',
name: '',
picture: '',
inBetweenSteps: true,
changeNickName: false,
changeProfilePicture: false,
identifier: added,
},
}}
>
<Provider closeModal={closeModal} existingId={integrations} />
</IntegrationContext.Provider>
);
};
const ContinueModal: FC<{
continueId: string;
added: any;
provider: any;
integrations: string[];
}> = (props) => {
const modals = useModals();
useEffect(() => {
modals.openModal({
title: 'Configure Channel',
children: (close) => <ModalContent {...props} closeModal={close} />,
});
}, []);
return null;
};

View File

@ -0,0 +1,157 @@
'use client';
import { FC, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import clsx from 'clsx';
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
export const GmbContinue: FC<{
closeModal: () => void;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [location, setSelectedLocation] = useState<null | {
id: string;
accountName: string;
locationName: string;
}>(null);
const fetch = useFetch();
const t = useT();
const loadPages = useCallback(async () => {
try {
const pages = await call.get('pages');
return pages;
} catch (e) {
closeModal();
}
}, []);
const setLocation = useCallback(
(param: { id: string; accountName: string; locationName: string }) => () => {
setSelectedLocation(param);
},
[]
);
const { data, isLoading } = useSWR('load-gmb-locations', loadPages, {
refreshWhenHidden: false,
refreshWhenOffline: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnMount: true,
revalidateOnReconnect: false,
refreshInterval: 0,
});
const saveGmb = useCallback(async () => {
await fetch(`/integrations/gmb/${integration?.id}`, {
method: 'POST',
body: JSON.stringify(location),
});
closeModal();
}, [integration, location]);
const filteredData = useMemo(() => {
return (
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
);
}, [data, existingId]);
if (!isLoading && !data?.length) {
return (
<div className="text-center flex flex-col justify-center items-center text-[18px] leading-[26px] h-[300px]">
{t(
'gmb_no_locations_found',
"We couldn't find any business locations connected to your account."
)}
<br />
<br />
{t(
'gmb_ensure_business_verified',
'Please ensure your business is verified on Google My Business.'
)}
<br />
<br />
{t(
'gmb_try_again',
'Please close this dialog, delete the integration and try again.'
)}
</div>
);
}
return (
<div className="flex flex-col gap-[20px]">
<div>{t('select_location', 'Select Business Location:')}</div>
<div className="grid grid-cols-3 justify-items-center select-none cursor-pointer gap-[10px]">
{filteredData?.map(
(p: {
id: string;
name: string;
accountName: string;
locationName: string;
picture: {
data: {
url: string;
};
};
}) => (
<div
key={p.id}
className={clsx(
'flex flex-col w-full text-center gap-[10px] border border-input p-[10px] hover:bg-seventh rounded-[8px]',
location?.id === p.id && 'bg-seventh border-primary'
)}
onClick={setLocation({
id: p.id,
accountName: p.accountName,
locationName: p.locationName,
})}
>
<div className="flex justify-center">
{p.picture?.data?.url ? (
<img
className="w-[80px] h-[80px] object-cover rounded-[8px]"
src={p.picture.data.url}
alt={p.name}
/>
) : (
<div className="w-[80px] h-[80px] bg-input rounded-[8px] flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
)}
</div>
<div className="text-sm font-medium">{p.name}</div>
</div>
)
)}
</div>
<div>
<Button disabled={!location} onClick={saveGmb}>
{t('save', 'Save')}
</Button>
</div>
</div>
);
};

View File

@ -3,8 +3,11 @@
import { InstagramContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/instagram/instagram.continue';
import { FacebookContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/facebook/facebook.continue';
import { LinkedinContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/linkedin/linkedin.continue';
import { GmbContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/gmb/gmb.continue';
export const continueProviderList = {
instagram: InstagramContinue,
facebook: FacebookContinue,
'linkedin-page': LinkedinContinue,
gmb: GmbContinue,
};

View File

@ -0,0 +1,190 @@
'use client';
import { FC, useCallback, useEffect } from 'react';
import {
PostComment,
withProvider,
} from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { Input } from '@gitroom/react/form/input';
import { Select } from '@gitroom/react/form/select';
import { useWatch } from 'react-hook-form';
const topicTypes = [
{
label: 'Standard Update',
value: 'STANDARD',
},
{
label: 'Event',
value: 'EVENT',
},
{
label: 'Offer',
value: 'OFFER',
},
];
const callToActionTypes = [
{
label: 'None',
value: 'NONE',
},
{
label: 'Book',
value: 'BOOK',
},
{
label: 'Order Online',
value: 'ORDER',
},
{
label: 'Shop',
value: 'SHOP',
},
{
label: 'Learn More',
value: 'LEARN_MORE',
},
{
label: 'Sign Up',
value: 'SIGN_UP',
},
{
label: 'Get Offer',
value: 'GET_OFFER',
},
{
label: 'Call',
value: 'CALL',
},
];
const GmbSettings: FC = () => {
const { register, control } = useSettings();
const topicType = useWatch({ control, name: 'topicType' });
const callToActionType = useWatch({ control, name: 'callToActionType' });
return (
<div className="flex flex-col gap-[10px]">
<Select
label="Post Type"
{...register('topicType', {
value: 'STANDARD',
})}
>
{topicTypes.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<Select
label="Call to Action"
{...register('callToActionType', {
value: 'NONE',
})}
>
{callToActionTypes.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
{callToActionType &&
callToActionType !== 'NONE' &&
callToActionType !== 'CALL' && (
<Input
label="Call to Action URL"
placeholder="https://example.com"
{...register('callToActionUrl')}
/>
)}
{topicType === 'EVENT' && (
<div className="flex flex-col gap-[10px] mt-[10px] p-[15px] border border-input rounded-[8px]">
<div className="text-[14px] font-medium mb-[5px]">Event Details</div>
<Input
label="Event Title"
placeholder="Event name"
{...register('eventTitle')}
/>
<div className="grid grid-cols-2 gap-[10px]">
<Input
label="Start Date"
type="date"
{...register('eventStartDate')}
/>
<Input label="End Date" type="date" {...register('eventEndDate')} />
</div>
<div className="grid grid-cols-2 gap-[10px]">
<Input
label="Start Time (optional)"
type="time"
{...register('eventStartTime')}
/>
<Input
label="End Time (optional)"
type="time"
{...register('eventEndTime')}
/>
</div>
</div>
)}
{topicType === 'OFFER' && (
<div className="flex flex-col gap-[10px] mt-[10px] p-[15px] border border-input rounded-[8px]">
<div className="text-[14px] font-medium mb-[5px]">Offer Details</div>
<Input
label="Coupon Code (optional)"
placeholder="SAVE20"
{...register('offerCouponCode')}
/>
<Input
label="Redeem Online URL (optional)"
placeholder="https://example.com/redeem"
{...register('offerRedeemUrl')}
/>
<Input
label="Terms & Conditions (optional)"
placeholder="Valid until..."
{...register('offerTerms')}
/>
</div>
)}
</div>
);
};
export default withProvider({
postComment: PostComment.POST,
minimumCharacters: [],
SettingsComponent: GmbSettings,
CustomPreviewComponent: undefined,
dto: GmbSettingsDto,
checkValidity: async (items, settings) => {
// GMB posts can have text only, or text with one image
if (items.length > 0 && items[0].length > 1) {
return 'Google My Business posts can only have one image';
}
// Check for video - GMB doesn't support video in local posts
if (items.length > 0 && items[0].length > 0) {
const media = items[0][0];
if (media.path.indexOf('mp4') > -1) {
return 'Google My Business posts do not support video attachments';
}
}
// Event posts require a title
if (settings.topicType === 'EVENT' && !settings.eventTitle) {
return 'Event posts require an event title';
}
return true;
},
maximumCharacters: 1500,
});

View File

@ -32,6 +32,7 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
import WordpressProvider from '@gitroom/frontend/components/new-launch/providers/wordpress/wordpress.provider';
import ListmonkProvider from '@gitroom/frontend/components/new-launch/providers/listmonk/listmonk.provider';
import GmbProvider from '@gitroom/frontend/components/new-launch/providers/gmb/gmb.provider';
export const Providers = [
{
@ -138,6 +139,10 @@ export const Providers = [
identifier: 'listmonk',
component: ListmonkProvider,
},
{
identifier: 'gmb',
component: GmbProvider,
},
];
export const ShowAllProviders = forwardRef((props, ref) => {
const { date, current, global, selectedIntegrations, allIntegrations } =

View File

@ -11,6 +11,7 @@ import {
import { Integration, Organization } from '@prisma/client';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
import { GmbProvider } from '@gitroom/nestjs-libraries/integrations/social/gmb.provider';
import dayjs from 'dayjs';
import { timer } from '@gitroom/helpers/utils/timer';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
@ -367,6 +368,40 @@ export class IntegrationService {
return { success: true };
}
async saveGmb(
org: string,
id: string,
data: { id: string; accountName: string; locationName: string }
) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
);
if (getIntegration && !getIntegration.inBetweenSteps) {
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
}
const gmb = this._integrationManager.getSocialIntegration(
'gmb'
) as GmbProvider;
const getIntegrationInformation = await gmb.fetchPageInformation(
getIntegration?.token!,
data
);
await this.checkForDeletedOnceAndUpdate(org, getIntegrationInformation.id);
await this._integrationRepository.updateIntegration(id, {
picture: getIntegrationInformation.picture,
internalId: getIntegrationInformation.id,
name: getIntegrationInformation.name,
inBetweenSteps: false,
token: getIntegration?.token!,
profile: getIntegrationInformation.username,
});
return { success: true };
}
async checkAnalytics(
org: Organization,
integration: string,

View File

@ -15,6 +15,7 @@ import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers
import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto';
import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto';
import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/listmonk.dto';
import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto';
export type ProviderExtension<T extends string, M> = { __type: T } & M;
export type AllProvidersSettings =
@ -36,6 +37,7 @@ export type AllProvidersSettings =
| ProviderExtension<'hashnode', HashnodeSettingsDto>
| ProviderExtension<'wordpress', WordpressDto>
| ProviderExtension<'listmonk', ListmonkDto>
| ProviderExtension<'gmb', GmbSettingsDto>
| ProviderExtension<'facebook', None>
| ProviderExtension<'threads', None>
| ProviderExtension<'mastodon', None>
@ -67,6 +69,7 @@ export const allProviders = (setEmpty?: any) => {
{ value: WordpressDto, name: 'wordpress' },
{ value: HashnodeSettingsDto, name: 'hashnode' },
{ value: ListmonkDto, name: 'listmonk' },
{ value: GmbSettingsDto, name: 'gmb' },
{ value: setEmpty, name: 'facebook' },
{ value: setEmpty, name: 'threads' },
{ value: setEmpty, name: 'mastodon' },

View File

@ -0,0 +1,69 @@
import { IsOptional, IsString, IsIn, IsUrl, ValidateIf } from 'class-validator';
export class GmbSettingsDto {
@IsOptional()
@IsIn(['STANDARD', 'EVENT', 'OFFER'])
topicType?: 'STANDARD' | 'EVENT' | 'OFFER';
@IsOptional()
@IsIn([
'NONE',
'BOOK',
'ORDER',
'SHOP',
'LEARN_MORE',
'SIGN_UP',
'GET_OFFER',
'CALL',
])
callToActionType?:
| 'NONE'
| 'BOOK'
| 'ORDER'
| 'SHOP'
| 'LEARN_MORE'
| 'SIGN_UP'
| 'GET_OFFER'
| 'CALL';
@IsOptional()
@ValidateIf((o) => o.callToActionType)
@IsUrl()
callToActionUrl?: string;
// Event-specific fields
@IsOptional()
@ValidateIf((o) => o.topicType === 'EVENT')
@IsString()
eventTitle?: string;
@IsOptional()
@IsString()
eventStartDate?: string;
@IsOptional()
@IsString()
eventEndDate?: string;
@IsOptional()
@IsString()
eventStartTime?: string;
@IsOptional()
@IsString()
eventEndTime?: string;
// Offer-specific fields
@IsOptional()
@IsString()
offerCouponCode?: string;
@IsOptional()
@ValidateIf((o) => o.offerRedeemUrl)
@IsUrl()
offerRedeemUrl?: string;
@IsOptional()
@IsString()
offerTerms?: string;
}

View File

@ -28,6 +28,7 @@ import { NostrProvider } from '@gitroom/nestjs-libraries/integrations/social/nos
import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.provider';
import { WordpressProvider } from '@gitroom/nestjs-libraries/integrations/social/wordpress.provider';
import { ListmonkProvider } from '@gitroom/nestjs-libraries/integrations/social/listmonk.provider';
import { GmbProvider } from '@gitroom/nestjs-libraries/integrations/social/gmb.provider';
export const socialIntegrationList: SocialProvider[] = [
new XProvider(),
@ -39,6 +40,7 @@ export const socialIntegrationList: SocialProvider[] = [
new FacebookProvider(),
new ThreadsProvider(),
new YoutubeProvider(),
new GmbProvider(),
new TiktokProvider(),
new PinterestProvider(),
new DribbbleProvider(),

View File

@ -0,0 +1,547 @@
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('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<AuthTokenDetails> {
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,
refreshToken: information.access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
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');
// Use the Business Profile Performance API
const response = await fetch(
`https://businessprofileperformance.googleapis.com/v1/${id}:getDailyMetricsTimeSeries?dailyMetric=WEBSITE_CLICKS&dailyMetric=CALL_CLICKS&dailyMetric=BUSINESS_DIRECTION_REQUESTS&dailyMetric=BUSINESS_IMPRESSIONS_DESKTOP_MAPS&dailyMetric=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();
if (!data.timeSeries || data.timeSeries.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 data.timeSeries) {
const metricName = series.dailyMetric;
const label = metricLabels[metricName] || metricName;
const dataPoints =
series.timeSeries?.datedValues?.map((dv: any) => ({
total: dv.value || 0,
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 [];
}
}
}