From 4eac6b9ff858a28900a9d915a67ea95b9db169eb Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 27 Nov 2025 20:37:09 +0700 Subject: [PATCH] feat: gmb --- .../src/api/routes/integrations.controller.ts | 9 + apps/frontend/public/icons/platforms/gmb.png | Bin 0 -> 16779 bytes .../launches/add.provider.component.tsx | 3 +- .../components/layout/continue.provider.tsx | 132 ++--- .../continue-provider/gmb/gmb.continue.tsx | 157 ++++++ .../providers/continue-provider/list.tsx | 3 + .../new-launch/providers/gmb/gmb.provider.tsx | 193 +++++++ .../providers/show.all.providers.tsx | 5 + .../integrations/integration.service.ts | 35 ++ .../all.providers.settings.ts | 3 + .../providers-settings/gmb.settings.dto.ts | 68 +++ .../src/integrations/integration.manager.ts | 2 + .../src/integrations/social/gmb.provider.ts | 518 ++++++++++++++++++ 13 files changed, 1061 insertions(+), 67 deletions(-) create mode 100644 apps/frontend/public/icons/platforms/gmb.png create mode 100644 apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/gmb/gmb.provider.tsx create mode 100644 libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts create mode 100644 libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index b2aaf372..c1c2990b 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -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; accountId: string; locationName: string }, + @GetOrgFromRequest() org: Organization + ) { + return this._integrationService.saveGmb(org.id, id, body); + } + @Post('/enable') enableChannel( @GetOrgFromRequest() org: Organization, diff --git a/apps/frontend/public/icons/platforms/gmb.png b/apps/frontend/public/icons/platforms/gmb.png new file mode 100644 index 0000000000000000000000000000000000000000..2ff782a9e9be0b0a799ecb110e619866608edde4 GIT binary patch literal 16779 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 N#>Y void) => { const modal = useModals(); @@ -457,7 +458,7 @@ export const AddProviderComponent: FC<{ ) : ( )} diff --git a/apps/frontend/src/components/layout/continue.provider.tsx b/apps/frontend/src/components/layout/continue.provider.tsx index 46d7f9c8..0e5a5660 100644 --- a/apps/frontend/src/components/layout/continue.provider.tsx +++ b/apps/frontend/src/components/layout/continue.provider.tsx @@ -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 ( -
-
e.stopPropagation()} - > -
- - -
- - p.internalId)} - /> - -
-
-
-
+ p.internalId)} + provider={Provider} + /> ); }; + +const ModalContent: FC<{ + continueId: string; + added: any; + provider: any; + closeModal: () => void; + integrations: string[]; +}> = ({ continueId, added, provider: Provider, closeModal, integrations }) => { + return ( + + + + ); +}; + +const ContinueModal: FC<{ + continueId: string; + added: any; + provider: any; + integrations: string[]; +}> = (props) => { + const modals = useModals(); + + useEffect(() => { + modals.openModal({ + title: 'Configure Channel', + children: (close) => , + }); + }, []); + + return null; +}; diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx new file mode 100644 index 00000000..54ceb1ec --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/gmb/gmb.continue.tsx @@ -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); + 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; accountId: 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 ( +
+ {t( + 'gmb_no_locations_found', + "We couldn't find any business locations connected to your account." + )} +
+
+ {t( + 'gmb_ensure_business_verified', + 'Please ensure your business is verified on Google My Business.' + )} +
+
+ {t( + 'gmb_try_again', + 'Please close this dialog, delete the integration and try again.' + )} +
+ ); + } + + return ( +
+
{t('select_location', 'Select Business Location:')}
+
+ {filteredData?.map( + (p: { + id: string; + name: string; + accountId: string; + locationName: string; + picture: { + data: { + url: string; + }; + }; + }) => ( +
+
+ {p.picture?.data?.url ? ( + {p.name} + ) : ( +
+ + + + +
+ )} +
+
{p.name}
+
+ ) + )} +
+
+ +
+
+ ); +}; + diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx index 03b182e2..f8052fae 100644 --- a/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/list.tsx @@ -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, }; diff --git a/apps/frontend/src/components/new-launch/providers/gmb/gmb.provider.tsx b/apps/frontend/src/components/new-launch/providers/gmb/gmb.provider.tsx new file mode 100644 index 00000000..2a1b428c --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/gmb/gmb.provider.tsx @@ -0,0 +1,193 @@ +'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: '', + }, + { + 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 ( +
+ + + + + {callToActionType && callToActionType !== 'CALL' && ( + + )} + + {topicType === 'EVENT' && ( +
+
Event Details
+ +
+ + +
+
+ + +
+
+ )} + + {topicType === 'OFFER' && ( +
+
Offer Details
+ + + +
+ )} +
+ ); +}; + +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, +}); + diff --git a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx index 9b1c9e51..187a9657 100644 --- a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx @@ -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 } = diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index a54758e6..ef843407 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -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; accountId: 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, diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts index 0e892e60..7579f460 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts @@ -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 = { __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' }, diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts new file mode 100644 index 00000000..221880d8 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts @@ -0,0 +1,68 @@ +import { IsOptional, IsString, IsIn, IsUrl, ValidateIf } from 'class-validator'; + +export class GmbSettingsDto { + @IsOptional() + @IsIn(['STANDARD', 'EVENT', 'OFFER']) + topicType?: 'STANDARD' | 'EVENT' | 'OFFER'; + + @IsOptional() + @IsIn([ + 'BOOK', + 'ORDER', + 'SHOP', + 'LEARN_MORE', + 'SIGN_UP', + 'GET_OFFER', + 'CALL', + ]) + callToActionType?: + | '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; +} + diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index dea5122d..a8ef22a4 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -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(), diff --git a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts new file mode 100644 index 00000000..002de587 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts @@ -0,0 +1,518 @@ +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 { + 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 } }; + accountId: 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) { + // 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: location.name, // format: locations/{locationId} + name: location.title || 'Unnamed Location', + picture: { data: { url: photoUrl } }, + accountId: 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; accountId: string; locationName: string } + ) { + // Fetch location details + const locationResponse = await fetch( + `https://mybusinessbusinessinformation.googleapis.com/v1/${data.id}?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.id}/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 { + id: data.id, + name: locationData.title || 'Unnamed Location', + access_token: accessToken, + picture: photoUrl, + username: '', + }; + } + + async reConnect( + id: string, + requiredId: string, + accessToken: string + ): Promise { + 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, + accountId: findPage.accountId, + 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[] + ): Promise { + 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 + if (settings?.callToActionType && 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 { + 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 []; + } + } +}