feat: unified continue providers

This commit is contained in:
Nevo David 2025-12-02 16:04:54 +07:00
parent e1225681a9
commit 292c3e480b
12 changed files with 98 additions and 273 deletions

View File

@ -533,49 +533,13 @@ export class IntegrationsController {
return this._integrationService.disableChannel(org.id, id);
}
@Post('/instagram/:id')
async saveInstagram(
@Post('/provider/:id/connect')
async saveProviderPage(
@Param('id') id: string,
@Body() body: { pageId: string; id: string },
@Body() body: any,
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveInstagram(org.id, id, body);
}
@Post('/facebook/:id')
async saveFacebook(
@Param('id') id: string,
@Body() body: { page: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveFacebook(org.id, id, body.page);
}
@Post('/linkedin-page/:id')
async saveLinkedin(
@Param('id') id: string,
@Body() body: { page: string },
@GetOrgFromRequest() org: Organization
) {
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('/youtube/:id')
async saveYoutube(
@Param('id') id: string,
@Body() body: { id: string },
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.saveYoutube(org.id, id, body);
return this._integrationService.saveProviderPage(org.id, id, body);
}
@Post('/enable')

View File

@ -9,7 +9,7 @@ import { continueProviderList } from '@gitroom/frontend/components/new-launch/pr
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
export const Null: FC<{
closeModal: () => void;
onSave: (data: any) => Promise<void>;
existingId: string[];
}> = () => null;
export const ContinueProvider: FC = () => {
@ -70,6 +70,19 @@ const ModalContent: FC<{
closeModal: () => void;
integrations: string[];
}> = ({ continueId, added, provider: Provider, closeModal, integrations }) => {
const fetch = useFetch();
const onSave = useCallback(
async (data: any) => {
await fetch(`/integrations/provider/${continueId}/connect`, {
method: 'POST',
body: JSON.stringify(data),
});
closeModal();
},
[continueId, closeModal]
);
return (
<IntegrationContext.Provider
value={{
@ -96,7 +109,7 @@ const ModalContent: FC<{
},
}}
>
<Provider closeModal={closeModal} existingId={integrations} />
<Provider onSave={onSave} existingId={integrations} />
</IntegrationContext.Provider>
);
};

View File

@ -4,25 +4,22 @@ 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 FacebookContinue: FC<{
closeModal: () => void;
onSave: (data: any) => Promise<void>;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const { onSave, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | string>(null);
const fetch = useFetch();
const loadPages = useCallback(async () => {
try {
const pages = await call.get('pages');
return pages;
} catch (e) {
closeModal();
// Handle error silently
}
}, []);
const setPage = useCallback(
@ -42,15 +39,9 @@ export const FacebookContinue: FC<{
});
const t = useT();
const saveInstagram = useCallback(async () => {
await fetch(`/integrations/facebook/${integration?.id}`, {
method: 'POST',
body: JSON.stringify({
page,
}),
});
closeModal();
}, [integration, page]);
const saveFacebook = useCallback(async () => {
await onSave({ page });
}, [onSave, page]);
const filteredData = useMemo(() => {
return (
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
@ -112,7 +103,7 @@ export const FacebookContinue: FC<{
)}
</div>
<div>
<Button disabled={!page} onClick={saveInstagram}>
<Button disabled={!page} onClick={saveFacebook}>
{t('save', 'Save')}
</Button>
</div>

View File

@ -4,24 +4,20 @@ 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;
onSave: (data: any) => Promise<void>;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const { onSave, 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 () => {
@ -29,7 +25,7 @@ export const GmbContinue: FC<{
const pages = await call.get('pages');
return pages;
} catch (e) {
closeModal();
// Handle error silently
}
}, []);
@ -51,12 +47,8 @@ export const GmbContinue: FC<{
});
const saveGmb = useCallback(async () => {
await fetch(`/integrations/gmb/${integration?.id}`, {
method: 'POST',
body: JSON.stringify(location),
});
closeModal();
}, [integration, location]);
await onSave(location);
}, [onSave, location]);
const filteredData = useMemo(() => {
return (

View File

@ -4,28 +4,25 @@ 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 InstagramContinue: FC<{
closeModal: () => void;
onSave: (data: any) => Promise<void>;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const { onSave, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | {
id: string;
pageId: string;
}>(null);
const fetch = useFetch();
const loadPages = useCallback(async () => {
try {
const pages = await call.get('pages');
return pages;
} catch (e) {
closeModal();
// Handle error silently
}
}, []);
const t = useT();
@ -46,12 +43,8 @@ export const InstagramContinue: FC<{
refreshInterval: 0,
});
const saveInstagram = useCallback(async () => {
await fetch(`/integrations/instagram/${integration?.id}`, {
method: 'POST',
body: JSON.stringify(page),
});
closeModal();
}, [integration, page]);
await onSave(page);
}, [onSave, page]);
const filteredData = useMemo(() => {
return (
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []
@ -103,7 +96,7 @@ export const InstagramContinue: FC<{
>
<div>
<img
className="w-full"
className="w-full max-w-[156px]"
src={p.picture.data.url}
alt="profile"
/>

View File

@ -4,30 +4,27 @@ 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 LinkedinContinue: FC<{
closeModal: () => void;
onSave: (data: any) => Promise<void>;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const { onSave, existingId } = props;
const t = useT();
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [page, setSelectedPage] = useState<null | {
id: string;
pageId: string;
}>(null);
const fetch = useFetch();
const loadPages = useCallback(async () => {
try {
const pages = await call.get('companies');
return pages;
} catch (e) {
closeModal();
// Handle error silently
}
}, []);
const setPage = useCallback(
@ -46,12 +43,8 @@ export const LinkedinContinue: FC<{
refreshInterval: 0,
});
const saveLinkedin = useCallback(async () => {
await fetch(`/integrations/linkedin-page/${integration?.id}`, {
method: 'POST',
body: JSON.stringify(page),
});
closeModal();
}, [integration, page]);
await onSave({ page: page?.id });
}, [onSave, page]);
const filteredData = useMemo(() => {
return (
data?.filter((p: { id: string }) => !existingId.includes(p.id)) || []

View File

@ -4,20 +4,16 @@ 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;
onSave: (data: any) => Promise<void>;
existingId: string[];
}> = (props) => {
const { closeModal, existingId } = props;
const { onSave, existingId } = props;
const call = useCustomProviderFunction();
const { integration } = useIntegration();
const [channel, setSelectedChannel] = useState<null | { id: string }>(null);
const fetch = useFetch();
const t = useT();
const loadChannels = useCallback(async () => {
@ -25,7 +21,7 @@ export const YoutubeContinue: FC<{
const channels = await call.get('pages');
return channels;
} catch (e) {
closeModal();
// Handle error silently
}
}, []);
@ -47,12 +43,8 @@ export const YoutubeContinue: FC<{
});
const saveYoutube = useCallback(async () => {
await fetch(`/integrations/youtube/${integration?.id}`, {
method: 'POST',
body: JSON.stringify(channel),
});
closeModal();
}, [integration, channel]);
await onSave(channel);
}, [onSave, channel]);
const filteredData = useMemo(() => {
return (

View File

@ -1,8 +1,6 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
import {
AnalyticsData,
AuthTokenDetails,
@ -10,9 +8,6 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
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';
@ -270,64 +265,39 @@ export class IntegrationService {
return this._integrationRepository.checkForDeletedOnceAndUpdate(org, page);
}
async saveInstagram(
org: string,
id: string,
data: { pageId: string; id: string }
) {
async saveProviderPage(org: string, id: string, data: any) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
);
if (getIntegration && !getIntegration.inBetweenSteps) {
if (!getIntegration) {
throw new HttpException('Integration not found', HttpStatus.NOT_FOUND);
}
if (!getIntegration.inBetweenSteps) {
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
}
const instagram = this._integrationManager.getSocialIntegration(
'instagram'
) as InstagramProvider;
const getIntegrationInformation = await instagram.fetchPageInformation(
getIntegration?.token!,
const provider = this._integrationManager.getSocialIntegration(
getIntegration.providerIdentifier
);
if (!provider.fetchPageInformation) {
throw new HttpException(
'Provider does not support page selection',
HttpStatus.BAD_REQUEST
);
}
const getIntegrationInformation = await provider.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: getIntegrationInformation.access_token,
profile: getIntegrationInformation.username,
});
return { success: true };
}
async saveLinkedin(org: string, id: string, page: string) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
);
if (getIntegration && !getIntegration.inBetweenSteps) {
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
}
const linkedin = this._integrationManager.getSocialIntegration(
'linkedin-page'
) as LinkedinPageProvider;
const getIntegrationInformation = await linkedin.fetchPageInformation(
getIntegration?.token!,
page
);
await this.checkForDeletedOnceAndUpdate(
org,
String(getIntegrationInformation.id)
);
await this._integrationRepository.updateIntegration(String(id), {
await this._integrationRepository.updateIntegration(id, {
picture: getIntegrationInformation.picture,
internalId: String(getIntegrationInformation.id),
name: getIntegrationInformation.name,
@ -339,100 +309,6 @@ export class IntegrationService {
return { success: true };
}
async saveFacebook(org: string, id: string, page: string) {
const getIntegration = await this._integrationRepository.getIntegrationById(
org,
id
);
if (getIntegration && !getIntegration.inBetweenSteps) {
throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST);
}
const facebook = this._integrationManager.getSocialIntegration(
'facebook'
) as FacebookProvider;
const getIntegrationInformation = await facebook.fetchPageInformation(
getIntegration?.token!,
page
);
await this.checkForDeletedOnceAndUpdate(org, getIntegrationInformation.id);
await this._integrationRepository.updateIntegration(id, {
picture: getIntegrationInformation.picture,
internalId: getIntegrationInformation.id,
name: getIntegrationInformation.name,
inBetweenSteps: false,
token: getIntegrationInformation.access_token,
profile: getIntegrationInformation.username,
});
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 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,

View File

@ -183,10 +183,9 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
requiredId: string,
accessToken: string
): Promise<AuthTokenDetails> {
const information = await this.fetchPageInformation(
accessToken,
requiredId
);
const information = await this.fetchPageInformation(accessToken, {
page: requiredId,
});
return {
id: information.id,
@ -266,7 +265,8 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
return data;
}
async fetchPageInformation(accessToken: string, pageId: string) {
async fetchPageInformation(accessToken: string, data: { page: string }) {
const pageId = data.page;
const {
id,
name,

View File

@ -150,10 +150,9 @@ export class LinkedinPageProvider
requiredId: string,
accessToken: string
): Promise<AuthTokenDetails> {
const information = await this.fetchPageInformation(
accessToken,
requiredId
);
const information = await this.fetchPageInformation(accessToken, {
page: requiredId,
});
return {
id: information.id,
@ -166,7 +165,8 @@ export class LinkedinPageProvider
};
}
async fetchPageInformation(accessToken: string, pageId: string) {
async fetchPageInformation(accessToken: string, params: { page: string }) {
const pageId = params.page;
const data = await (
await fetch(
`https://api.linkedin.com/v2/organizations/${pageId}?projection=(id,localizedName,vanityName,logoV2(original~:playableStreams))`,

View File

@ -107,6 +107,14 @@ export type MediaContent = {
thumbnailTimestamp?: number;
};
export type FetchPageInformationResult = {
id: string;
name: string;
access_token: string;
picture: string;
username: string;
};
export interface SocialProvider
extends IAuthenticator,
ISocialMediaIntegration {
@ -138,4 +146,8 @@ export interface SocialProvider
token: string, data: { query: string }, id: string, integration: Integration
) => Promise<{ id: string; label: string; image: string, doNotCache?: boolean }[] | {none: true}>;
mentionFormat?(idOrHandle: string, name: string): string;
fetchPageInformation?(
accessToken: string,
data: any
): Promise<FetchPageInformationResult>;
}

View File

@ -39,7 +39,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)
).json();
const { id, name, username, picture } = await this.fetchPageInformation(
const { id, name, username, picture } = await this.fetchUserInfo(
access_token
);
@ -49,7 +49,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: picture?.data?.url || '',
picture: picture || '',
username: '',
};
}
@ -105,7 +105,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
)
).json();
const { id, name, username, picture } = await this.fetchPageInformation(
const { id, name, username, picture } = await this.fetchUserInfo(
access_token
);
@ -115,7 +115,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: picture?.data?.url || '',
picture: picture || '',
username: username,
};
}
@ -143,8 +143,8 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
return this.checkLoaded(mediaContainerId, accessToken);
}
async fetchPageInformation(accessToken: string) {
const { id, username, threads_profile_picture_url, access_token } = await (
private async fetchUserInfo(accessToken: string) {
const { id, username, threads_profile_picture_url } = await (
await this.fetch(
`https://graph.threads.net/v1.0/me?fields=id,username,threads_profile_picture_url&access_token=${accessToken}`
)
@ -153,8 +153,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
return {
id,
name: username,
access_token,
picture: { data: { url: threads_profile_picture_url } },
picture: threads_profile_picture_url || '',
username,
};
}