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 00000000..2ff782a9 Binary files /dev/null and b/apps/frontend/public/icons/platforms/gmb.png differ diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index b2d3ea98..8273fc1b 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -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<{ ) : ( )} 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 []; + } + } +}