diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 3cd717c1..743843de 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -569,6 +569,15 @@ export class IntegrationsController { return this._integrationService.saveGmb(org.id, id, body); } + @Post('/youtube/:id') + async saveYoutube( + @Param('id') id: string, + @Body() body: { id: string }, + @GetOrgFromRequest() org: Organization + ) { + return this._integrationService.saveYoutube(org.id, id, body); + } + @Post('/enable') enableChannel( @GetOrgFromRequest() org: Organization, 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 f8052fae..3331557b 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 @@ -4,10 +4,12 @@ import { InstagramContinue } from '@gitroom/frontend/components/new-launch/provi 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'; +import { YoutubeContinue } from '@gitroom/frontend/components/new-launch/providers/continue-provider/youtube/youtube.continue'; export const continueProviderList = { instagram: InstagramContinue, facebook: FacebookContinue, 'linkedin-page': LinkedinContinue, gmb: GmbContinue, + youtube: YoutubeContinue, }; diff --git a/apps/frontend/src/components/new-launch/providers/continue-provider/youtube/youtube.continue.tsx b/apps/frontend/src/components/new-launch/providers/continue-provider/youtube/youtube.continue.tsx new file mode 100644 index 00000000..5798c0b4 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/continue-provider/youtube/youtube.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 YoutubeContinue: FC<{ + closeModal: () => void; + existingId: string[]; +}> = (props) => { + const { closeModal, existingId } = props; + const call = useCustomProviderFunction(); + const { integration } = useIntegration(); + const [channel, setSelectedChannel] = useState(null); + const fetch = useFetch(); + const t = useT(); + + const loadChannels = useCallback(async () => { + try { + const channels = await call.get('pages'); + return channels; + } catch (e) { + closeModal(); + } + }, []); + + const setChannel = useCallback( + (param: { id: string }) => () => { + setSelectedChannel(param); + }, + [] + ); + + const { data, isLoading } = useSWR('load-youtube-channels', loadChannels, { + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + refreshInterval: 0, + }); + + const saveYoutube = useCallback(async () => { + await fetch(`/integrations/youtube/${integration?.id}`, { + method: 'POST', + body: JSON.stringify(channel), + }); + closeModal(); + }, [integration, channel]); + + const filteredData = useMemo(() => { + return ( + data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] + ); + }, [data, existingId]); + + if (!isLoading && !data?.length) { + return ( +
+ {t( + 'youtube_no_channels_found', + "We couldn't find any YouTube channels connected to your account." + )} +
+
+ {t( + 'youtube_ensure_channel_exists', + 'Please ensure you have a YouTube channel created.' + )} +
+
+ {t( + 'youtube_try_again', + 'Please close this dialog, delete the integration and try again.' + )} +
+ ); + } + + return ( +
+
{t('select_channel', 'Select YouTube Channel:')}
+
+ {filteredData?.map( + (p: { + id: string; + name: string; + username: string; + subscriberCount: string; + picture: { + data: { + url: string; + }; + }; + }) => ( +
+
+ {p.picture?.data?.url ? ( + {p.name} + ) : ( +
+ + + + +
+ )} +
+
{p.name}
+ {p.username && ( +
{p.username}
+ )} + {p.subscriberCount && ( +
+ {parseInt(p.subscriberCount).toLocaleString()} subscribers +
+ )} +
+ ) + )} +
+
+ +
+
+ ); +}; + 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 554c6c35..26cd9b15 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -12,6 +12,7 @@ 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 { YoutubeProvider } from '@gitroom/nestjs-libraries/integrations/social/youtube.provider'; import dayjs from 'dayjs'; import { timer } from '@gitroom/helpers/utils/timer'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; @@ -402,6 +403,36 @@ export class IntegrationService { return { success: true }; } + async saveYoutube(org: string, id: string, data: { id: string }) { + const getIntegration = await this._integrationRepository.getIntegrationById( + org, + id + ); + if (getIntegration && !getIntegration.inBetweenSteps) { + throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST); + } + + const youtube = this._integrationManager.getSocialIntegration( + 'youtube' + ) as YoutubeProvider; + const getIntegrationInformation = await youtube.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/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 36bffc53..890ba1a5 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -53,7 +53,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { override maxConcurrentJob = 1; // YouTube has strict upload quotas identifier = 'youtube'; name = 'YouTube'; - isBetweenSteps = false; + isBetweenSteps = true; dto = YoutubeSettingsDto; scopes = [ 'https://www.googleapis.com/auth/userinfo.profile', @@ -189,6 +189,97 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { }; } + async pages(accessToken: string) { + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtube(client); + + try { + // Get all channels the user has access to + const response = await youtubeClient.channels.list({ + part: ['snippet', 'contentDetails', 'statistics'], + mine: true, + }); + + const channels = response.data.items || []; + + return channels.map((channel) => ({ + id: channel.id!, + name: channel.snippet?.title || 'Unnamed Channel', + picture: { + data: { + url: channel.snippet?.thumbnails?.default?.url || '', + }, + }, + username: channel.snippet?.customUrl || '', + subscriberCount: channel.statistics?.subscriberCount || '0', + })); + } catch (error) { + console.error('Failed to fetch YouTube channels:', error); + return []; + } + } + + async fetchPageInformation( + accessToken: string, + data: { id: string } + ) { + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtube(client); + + try { + const response = await youtubeClient.channels.list({ + part: ['snippet', 'contentDetails', 'statistics'], + id: [data.id], + }); + + const channel = response.data.items?.[0]; + + if (!channel) { + throw new Error('Channel not found'); + } + + return { + id: channel.id!, + name: channel.snippet?.title || 'Unnamed Channel', + access_token: accessToken, + picture: channel.snippet?.thumbnails?.default?.url || '', + username: channel.snippet?.customUrl || '', + }; + } catch (error) { + console.error('Failed to fetch YouTube channel information:', error); + throw error; + } + } + + 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('Channel not found'); + } + + const information = await this.fetchPageInformation(accessToken, { + id: requiredId, + }); + + 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,