diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index aded60b2..2fb340df 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -261,6 +261,15 @@ export class IntegrationsController { 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('/enable') enableChannel( @GetOrgFromRequest() org: Organization, diff --git a/apps/frontend/next.config.js b/apps/frontend/next.config.js index 8edcfcf5..34610438 100644 --- a/apps/frontend/next.config.js +++ b/apps/frontend/next.config.js @@ -12,13 +12,20 @@ const nextConfig = { // See: https://github.com/gregberge/svgr svgr: false, }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], + }, env: { isBillingEnabled: String(!!process.env.STRIPE_PUBLISHABLE_KEY), isGeneral: String(!!process.env.IS_GENERAL), - } + }, }; - const plugins = [ // Add more Next.js plugins to this list if needed. withNx, diff --git a/apps/frontend/public/icons/platforms/dribbble.png b/apps/frontend/public/icons/platforms/dribbble.png new file mode 100644 index 00000000..4d3aa535 Binary files /dev/null and b/apps/frontend/public/icons/platforms/dribbble.png differ diff --git a/apps/frontend/public/icons/platforms/linkedin-page.png b/apps/frontend/public/icons/platforms/linkedin-page.png new file mode 100644 index 00000000..3ce04bcd Binary files /dev/null and b/apps/frontend/public/icons/platforms/linkedin-page.png differ diff --git a/apps/frontend/public/postiz-fav.png b/apps/frontend/public/postiz-fav.png new file mode 100644 index 00000000..64a1cf0b Binary files /dev/null and b/apps/frontend/public/postiz-fav.png differ diff --git a/apps/frontend/src/app/(site)/launches/page.tsx b/apps/frontend/src/app/(site)/launches/page.tsx index 2c3b5948..82e36764 100644 --- a/apps/frontend/src/app/(site)/launches/page.tsx +++ b/apps/frontend/src/app/(site)/launches/page.tsx @@ -6,7 +6,7 @@ import {LaunchesComponent} from "@gitroom/frontend/components/launches/launches. import {Metadata} from "next"; export const metadata: Metadata = { - title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Launches`, + title: `${isGeneral() ? 'Postiz Calendar' : 'Gitroom Launches'}`, description: '', } diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 660310ef..9841de5c 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -7,6 +7,7 @@ import 'react-tooltip/dist/react-tooltip.css'; import LayoutContext from '@gitroom/frontend/components/layout/layout.context'; import { ReactNode } from 'react'; import { Chakra_Petch } from 'next/font/google'; +import { isGeneral } from '@gitroom/react/helpers/is.general'; const chakra = Chakra_Petch({ weight: '400', subsets: ['latin'] }); @@ -14,7 +15,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index 534c9086..2b8b1239 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -16,6 +16,7 @@ import { Menu } from '@gitroom/frontend/components/launches/menu/menu'; import { GeneratorComponent } from '@gitroom/frontend/components/launches/generator/generator'; import { useRouter } from 'next/navigation'; import { Integration } from '@prisma/client'; +import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback'; export const LaunchesComponent = () => { const fetch = useFetch(); @@ -71,10 +72,15 @@ export const LaunchesComponent = () => { ); const refreshChannel = useCallback( - (integration: Integration & {identifier: string}) => async () => { - const {url} = await (await fetch(`/integrations/social/${integration.identifier}?refresh=${integration.internalId}`, { - method: 'GET', - })).json(); + (integration: Integration & { identifier: string }) => async () => { + const { url } = await ( + await fetch( + `/integrations/social/${integration.identifier}?refresh=${integration.internalId}`, + { + method: 'GET', + } + ) + ).json(); window.location.href = url; }, @@ -134,7 +140,8 @@ export const LaunchesComponent = () => {
)} - {integration.identifier} void; + existingId: string[]; +}> = (props) => { + const { closeModal, existingId } = props; + const call = useCustomProviderFunction(); + const { integration } = useIntegration(); + const [page, setSelectedPage] = useState(null); + const fetch = useFetch(); + + const loadPages = useCallback(async () => { + try { + const pages = await call.get('companies'); + return pages; + } catch (e) { + closeModal(); + } + }, []); + + const setPage = useCallback( + (param: { id: string; pageId: string }) => () => { + setSelectedPage(param); + }, + [] + ); + + const { data } = useSWR('load-pages', loadPages, { + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + refreshInterval: 0, + }); + + const saveLinkedin = useCallback(async () => { + await fetch(`/integrations/linkedin-page/${integration?.id}`, { + method: 'POST', + body: JSON.stringify(page), + }); + + closeModal(); + }, [integration, page]); + + const filteredData = useMemo(() => { + return ( + data?.filter((p: { id: string }) => !existingId.includes(p.id)) || [] + ); + }, [data]); + + return ( +
+
Select Linkedin Account:
+
+ {filteredData?.map( + (p: { + id: string; + pageId: string; + username: string; + name: string; + picture: string; + }) => ( +
+
+ profile +
+
{p.name}
+
+ ) + )} +
+
+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/launches/providers/continue-provider/list.tsx b/apps/frontend/src/components/launches/providers/continue-provider/list.tsx index 0bec9f55..7ff24102 100644 --- a/apps/frontend/src/components/launches/providers/continue-provider/list.tsx +++ b/apps/frontend/src/components/launches/providers/continue-provider/list.tsx @@ -1,7 +1,9 @@ import { InstagramContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/instagram/instagram.continue'; import { FacebookContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/facebook/facebook.continue'; +import { LinkedinContinue } from '@gitroom/frontend/components/launches/providers/continue-provider/linkedin/linkedin.continue'; export const continueProviderList = { instagram: InstagramContinue, - facebook: FacebookContinue -} \ No newline at end of file + facebook: FacebookContinue, + 'linkedin-page': LinkedinContinue, +}; diff --git a/apps/frontend/src/components/launches/providers/show.all.providers.tsx b/apps/frontend/src/components/launches/providers/show.all.providers.tsx index a4996015..2ee9aa87 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -16,6 +16,7 @@ export const Providers = [ {identifier: 'devto', component: DevtoProvider}, {identifier: 'x', component: XProvider}, {identifier: 'linkedin', component: LinkedinProvider}, + {identifier: 'linkedin-page', component: LinkedinProvider}, {identifier: 'reddit', component: RedditProvider}, {identifier: 'medium', component: MediumProvider}, {identifier: 'hashnode', component: HashnodeProvider}, 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 d9ec0be0..2f201b73 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -4,9 +4,9 @@ import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integ import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider'; import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider'; import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { Integration, Organization } from '@prisma/client'; +import { Integration } from '@prisma/client'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; -import { YoutubeProvider } from '@gitroom/nestjs-libraries/integrations/social/youtube.provider'; +import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider'; @Injectable() export class IntegrationService { @@ -86,6 +86,10 @@ export class IntegrationService { ); } + async refreshNeeded(org: string, id: string) { + return this._integrationRepository.refreshNeeded(org, id); + } + async refreshTokens() { const integrations = await this._integrationRepository.needsToBeRefreshed(); for (const integration of integrations) { @@ -195,6 +199,38 @@ export class IntegrationService { 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), { + picture: getIntegrationInformation.picture, + internalId: String(getIntegrationInformation.id), + name: getIntegrationInformation.name, + inBetweenSteps: false, + token: getIntegrationInformation.access_token, + profile: getIntegrationInformation.username, + }); + + return { success: true }; + } + async saveFacebook(org: string, id: string, page: string) { const getIntegration = await this._integrationRepository.getIntegrationById( org, diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 7bacf814..80002293 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -16,6 +16,7 @@ import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; type PostWithConditionals = Post & { integration?: Integration; @@ -145,7 +146,9 @@ export class PostsService { await this._notificationService.inAppNotification( firstPost.organizationId, `Error posting on ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`, - `An error occurred while posting on ${firstPost.integration?.providerIdentifier} ${JSON.stringify(err)}`, + `An error occurred while posting on ${ + firstPost.integration?.providerIdentifier + } ${JSON.stringify(err)}`, true ); } @@ -173,19 +176,33 @@ export class PostsService { return this.updateTags(orgId, JSON.parse(newPlainText) as Post[]); } - private async postSocial(integration: Integration, posts: Post[]) { + private async postSocial( + integration: Integration, + posts: Post[], + forceRefresh = false + ): Promise> { const getIntegration = this._integrationManager.getSocialIntegration( integration.providerIdentifier ); if (!getIntegration) { - return; + return {}; } - if (dayjs(integration?.tokenExpiration).isBefore(dayjs())) { + if (dayjs(integration?.tokenExpiration).isBefore(dayjs()) || forceRefresh) { const { accessToken, expiresIn, refreshToken } = await getIntegration.refreshToken(integration.refreshToken!); + if (!accessToken) { + await this._integrationService.refreshNeeded( + integration.organizationId, + integration.id + ); + + await this._integrationService.informAboutRefreshError(integration.organizationId, integration); + return {}; + } + await this._integrationService.createOrUpdateIntegration( integration.organizationId, integration.name, @@ -203,51 +220,59 @@ export class PostsService { const newPosts = await this.updateTags(integration.organizationId, posts); - const publishedPosts = await getIntegration.post( - integration.internalId, - integration.token, - newPosts.map((p) => ({ - id: p.id, - message: p.content, - settings: JSON.parse(p.settings || '{}'), - media: (JSON.parse(p.image || '[]') as Media[]).map((m) => ({ - url: - m.path.indexOf('http') === -1 - ? process.env.FRONTEND_URL + - '/' + - process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY + - m.path - : m.path, - type: 'image', - path: - m.path.indexOf('http') === -1 - ? process.env.UPLOAD_DIRECTORY + m.path - : m.path, - })), - })) - ); - - for (const post of publishedPosts) { - await this._postRepository.updatePost( - post.id, - post.postId, - post.releaseURL + try { + const publishedPosts = await getIntegration.post( + integration.internalId, + integration.token, + newPosts.map((p) => ({ + id: p.id, + message: p.content, + settings: JSON.parse(p.settings || '{}'), + media: (JSON.parse(p.image || '[]') as Media[]).map((m) => ({ + url: + m.path.indexOf('http') === -1 + ? process.env.FRONTEND_URL + + '/' + + process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY + + m.path + : m.path, + type: 'image', + path: + m.path.indexOf('http') === -1 + ? process.env.UPLOAD_DIRECTORY + m.path + : m.path, + })), + })) ); + + for (const post of publishedPosts) { + await this._postRepository.updatePost( + post.id, + post.postId, + post.releaseURL + ); + } + + await this._notificationService.inAppNotification( + integration.organizationId, + `Your post has been published on ${capitalize( + integration.providerIdentifier + )}`, + `Your post has been published at ${publishedPosts[0].releaseURL}`, + true + ); + + return { + postId: publishedPosts[0].postId, + releaseURL: publishedPosts[0].releaseURL, + }; + } catch (err) { + if (err instanceof RefreshToken) { + return this.postSocial(integration, posts, true); + } + + throw err; } - - await this._notificationService.inAppNotification( - integration.organizationId, - `Your post has been published on ${capitalize( - integration.providerIdentifier - )}`, - `Your post has been published at ${publishedPosts[0].releaseURL}`, - true - ); - - return { - postId: publishedPosts[0].postId, - releaseURL: publishedPosts[0].releaseURL, - }; } private async postArticle(integration: Integration, posts: Post[]) { diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index efa47538..c0e93cda 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -12,16 +12,20 @@ import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social import { YoutubeProvider } from '@gitroom/nestjs-libraries/integrations/social/youtube.provider'; import { TiktokProvider } from '@gitroom/nestjs-libraries/integrations/social/tiktok.provider'; import { PinterestProvider } from '@gitroom/nestjs-libraries/integrations/social/pinterest.provider'; +import { DribbbleProvider } from '@gitroom/nestjs-libraries/integrations/social/dribbble.provider'; +import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider'; const socialIntegrationList = [ new XProvider(), new LinkedinProvider(), + new LinkedinPageProvider(), new RedditProvider(), new FacebookProvider(), new InstagramProvider(), new YoutubeProvider(), new TiktokProvider(), - new PinterestProvider() + new PinterestProvider(), + new DribbbleProvider(), ]; const articleIntegrationList = [ diff --git a/libraries/nestjs-libraries/src/integrations/social.abstract.ts b/libraries/nestjs-libraries/src/integrations/social.abstract.ts new file mode 100644 index 00000000..52439fdb --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts @@ -0,0 +1,13 @@ +export class RefreshToken { +} + +export abstract class SocialAbstract { + async fetch(url: string, options: RequestInit = {}) { + const request = await fetch(url, options); + if (request.status === 401) { + throw new RefreshToken(); + } + + return request; + } +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts new file mode 100644 index 00000000..e892e9ae --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts @@ -0,0 +1,271 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto'; +import axios from 'axios'; +import FormData from 'form-data'; +import { timer } from '@gitroom/helpers/utils/timer'; +import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; + +export class DribbbleProvider extends SocialAbstract implements SocialProvider { + identifier = 'dribbble'; + name = 'Dribbbble'; + isBetweenSteps = false; + + async refreshToken(refreshToken: string): Promise { + const { access_token, expires_in } = await ( + await this.fetch('https://api-sandbox.pinterest.com/v5/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${process.env.PINTEREST_CLIENT_ID}:${process.env.PINTEREST_CLIENT_SECRET}` + ).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: + 'boards:read,boards:write,pins:read,pins:write,user_accounts:read', + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/pinterest`, + }), + }) + ).json(); + + const { id, profile_image, username } = await ( + await this.fetch('https://api-sandbox.pinterest.com/v5/user_account', { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: id, + name: username, + accessToken: access_token, + refreshToken: refreshToken, + expiresIn: expires_in, + picture: profile_image, + username, + }; + } + + async teams(accessToken: string) { + const { teams } = await ( + await this.fetch('https://api.dribbble.com/v2/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return teams?.map((team: any) => ({ + id: team.id, + name: team.name, + })) || []; + } + + async generateAuthUrl(refresh?: string) { + const state = makeId(6); + return { + url: `https://dribbble.com/oauth/authorize?client_id=${ + process.env.DRIBBBLE_CLIENT_ID + }&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/dribbble${ + refresh ? `?refresh=${refresh}` : '' + }` + )}&response_type=code&scope=public+upload&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh: string; + }) { + const { access_token } = await ( + await this.fetch( + `https://dribbble.com/oauth/token?client_id=${process.env.DRIBBBLE_CLIENT_ID}&client_secret=${process.env.DRIBBBLE_CLIENT_SECRET}&code=${params.code}&redirect_uri=${process.env.FRONTEND_URL}/integrations/social/dribbble`, + { + method: 'POST', + } + ) + ).json(); + + const { id, name, avatar_url, login } = await ( + await this.fetch('https://api.dribbble.com/v2/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: id, + name, + accessToken: access_token, + refreshToken: '', + expiresIn: 999999999, + picture: avatar_url, + username: login, + }; + } + + async boards(accessToken: string) { + const { items } = await ( + await this.fetch('https://api-sandbox.pinterest.com/v5/boards', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return ( + items?.map((item: any) => ({ + name: item.name, + id: item.id, + })) || [] + ); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + let mediaId = ''; + const findMp4 = postDetails?.[0]?.media?.find( + (p) => (p.path?.indexOf('mp4') || -1) > -1 + ); + const picture = postDetails?.[0]?.media?.find( + (p) => (p.path?.indexOf('mp4') || -1) === -1 + ); + + if (findMp4) { + const { upload_url, media_id, upload_parameters } = await ( + await this.fetch('https://api-sandbox.pinterest.com/v5/media', { + method: 'POST', + body: JSON.stringify({ + media_type: 'video', + }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { data, status } = await axios.get( + postDetails?.[0]?.media?.[0]?.url!, + { + responseType: 'stream', + } + ); + + const formData = Object.keys(upload_parameters) + .filter((f) => f) + .reduce((acc, key) => { + acc.append(key, upload_parameters[key]); + return acc; + }, new FormData()); + + formData.append('file', data); + await axios.post(upload_url, formData); + + let statusCode = ''; + while (statusCode !== 'succeeded') { + console.log('trying'); + const mediafile = await ( + await this.fetch( + 'https://api-sandbox.pinterest.com/v5/media/' + media_id, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + await timer(3000); + statusCode = mediafile.status; + } + + mediaId = media_id; + } + + const mapImages = postDetails?.[0]?.media?.map((m) => ({ + url: m.url, + })); + + try { + const { + id: pId, + link, + ...all + } = await ( + await this.fetch('https://api-sandbox.pinterest.com/v5/pins', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...(postDetails?.[0]?.settings.link + ? { link: postDetails?.[0]?.settings.link } + : {}), + ...(postDetails?.[0]?.settings.title + ? { title: postDetails?.[0]?.settings.title } + : {}), + ...(postDetails?.[0]?.settings.description + ? { title: postDetails?.[0]?.settings.description } + : {}), + ...(postDetails?.[0]?.settings.dominant_color + ? { title: postDetails?.[0]?.settings.dominant_color } + : {}), + board_id: postDetails?.[0]?.settings.board, + media_source: mediaId + ? { + source_type: 'video_id', + media_id: mediaId, + cover_image_url: picture?.url, + } + : mapImages?.length === 1 + ? { + source_type: 'image_url', + url: mapImages?.[0]?.url, + } + : { + source_type: 'multiple_image_urls', + items: mapImages, + }, + }), + }) + ).json(); + + return [ + { + id: postDetails?.[0]?.id, + postId: pId, + releaseURL: `https://www.pinterest.com/pin/${pId}`, + status: 'success', + }, + ]; + } catch (err) { + console.log(err); + return []; + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index 8785dc08..d8824364 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -6,8 +6,9 @@ import { } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -export class FacebookProvider implements SocialProvider { +export class FacebookProvider extends SocialAbstract implements SocialProvider { identifier = 'facebook'; name = 'Facebook Page'; isBetweenSteps = true; @@ -46,7 +47,7 @@ export class FacebookProvider implements SocialProvider { refresh?: string; }) { const getAccessToken = await ( - await fetch( + await this.fetch( 'https://graph.facebook.com/v20.0/oauth/access_token' + `?client_id=${process.env.FACEBOOK_APP_ID}` + `&redirect_uri=${encodeURIComponent( @@ -60,7 +61,7 @@ export class FacebookProvider implements SocialProvider { ).json(); const { access_token } = await ( - await fetch( + await this.fetch( 'https://graph.facebook.com/v20.0/oauth/access_token' + '?grant_type=fb_exchange_token' + `&client_id=${process.env.FACEBOOK_APP_ID}` + @@ -92,7 +93,7 @@ export class FacebookProvider implements SocialProvider { data: { url }, }, } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}` ) ).json(); @@ -110,7 +111,7 @@ export class FacebookProvider implements SocialProvider { async pages(accessToken: string) { const { data } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture.type(large)&access_token=${accessToken}` ) ).json(); @@ -128,7 +129,7 @@ export class FacebookProvider implements SocialProvider { data: { url }, }, } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${pageId}?fields=username,access_token,name,picture.type(large)&access_token=${accessToken}` ) ).json(); @@ -153,7 +154,7 @@ export class FacebookProvider implements SocialProvider { let finalUrl = ''; if ((firstPost?.media?.[0]?.path?.indexOf('mp4') || -2) > -1) { const { id: videoId, permalink_url } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/videos?access_token=${accessToken}&fields=id,permalink_url`, { method: 'POST', @@ -177,7 +178,7 @@ export class FacebookProvider implements SocialProvider { : await Promise.all( firstPost.media.map(async (media) => { const { id: photoId } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/photos?access_token=${accessToken}`, { method: 'POST', @@ -201,7 +202,7 @@ export class FacebookProvider implements SocialProvider { permalink_url, ...all } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/feed?access_token=${accessToken}&fields=id,permalink_url`, { method: 'POST', @@ -224,7 +225,7 @@ export class FacebookProvider implements SocialProvider { const postsArray = []; for (const comment of comments) { const data = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${finalId}/comments?access_token=${accessToken}&fields=id,permalink_url`, { method: 'POST', diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index 8f11cca8..37b6be8f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -7,8 +7,9 @@ import { import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { timer } from '@gitroom/helpers/utils/timer'; import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -export class InstagramProvider implements SocialProvider { +export class InstagramProvider extends SocialAbstract implements SocialProvider { identifier = 'instagram'; name = 'Instagram'; isBetweenSteps = true; @@ -51,7 +52,7 @@ export class InstagramProvider implements SocialProvider { refresh: string; }) { const getAccessToken = await ( - await fetch( + await this.fetch( 'https://graph.facebook.com/v20.0/oauth/access_token' + `?client_id=${process.env.FACEBOOK_APP_ID}` + `&redirect_uri=${encodeURIComponent( @@ -65,7 +66,7 @@ export class InstagramProvider implements SocialProvider { ).json(); const { access_token, expires_in, ...all } = await ( - await fetch( + await this.fetch( 'https://graph.facebook.com/v20.0/oauth/access_token' + '?grant_type=fb_exchange_token' + `&client_id=${process.env.FACEBOOK_APP_ID}` + @@ -81,7 +82,7 @@ export class InstagramProvider implements SocialProvider { data: { url }, }, } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}` ) ).json(); @@ -117,7 +118,7 @@ export class InstagramProvider implements SocialProvider { async pages(accessToken: string) { const { data } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/me/accounts?fields=id,instagram_business_account,username,name,picture.type(large)&access_token=${accessToken}&limit=500` ) ).json(); @@ -129,7 +130,7 @@ export class InstagramProvider implements SocialProvider { return { pageId: p.id, ...(await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${p.instagram_business_account.id}?fields=name,profile_picture_url&access_token=${accessToken}&limit=500` ) ).json()), @@ -151,13 +152,13 @@ export class InstagramProvider implements SocialProvider { data: { pageId: string; id: string } ) { const { access_token, ...all } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${data.pageId}?fields=access_token,name,picture.type(large)&access_token=${accessToken}` ) ).json(); const { id, name, profile_picture_url, username } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${data.id}?fields=username,name,profile_picture_url&access_token=${accessToken}` ) ).json(); @@ -191,7 +192,7 @@ export class InstagramProvider implements SocialProvider { : `video_url=${m.url}&media_type=VIDEO` : `image_url=${m.url}`; const { id: photoId } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/media?${mediaType}${caption}${isCarousel}&access_token=${accessToken}`, { method: 'POST', @@ -202,7 +203,7 @@ export class InstagramProvider implements SocialProvider { let status = 'IN_PROGRESS'; while (status === 'IN_PROGRESS') { const { status_code } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${photoId}?access_token=${accessToken}&fields=status_code` ) ).json(); @@ -220,7 +221,7 @@ export class InstagramProvider implements SocialProvider { let linkGlobal = ''; if (medias.length === 1) { const { id: mediaId } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/media_publish?creation_id=${medias[0]}&access_token=${accessToken}&field=id`, { method: 'POST', @@ -231,7 +232,7 @@ export class InstagramProvider implements SocialProvider { containerIdGlobal = mediaId; const { permalink } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}` ) ).json(); @@ -246,7 +247,7 @@ export class InstagramProvider implements SocialProvider { linkGlobal = permalink; } else { const { id: containerId, ...all3 } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/media?caption=${encodeURIComponent( firstPost?.message )}&media_type=CAROUSEL&children=${encodeURIComponent( @@ -261,7 +262,7 @@ export class InstagramProvider implements SocialProvider { let status = 'IN_PROGRESS'; while (status === 'IN_PROGRESS') { const { status_code } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${containerId}?fields=status_code&access_token=${accessToken}` ) ).json(); @@ -270,7 +271,7 @@ export class InstagramProvider implements SocialProvider { } const { id: mediaId, ...all4 } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/media_publish?creation_id=${containerId}&access_token=${accessToken}&field=id`, { method: 'POST', @@ -281,7 +282,7 @@ export class InstagramProvider implements SocialProvider { containerIdGlobal = mediaId; const { permalink } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}` ) ).json(); @@ -298,7 +299,7 @@ export class InstagramProvider implements SocialProvider { for (const post of theRest) { const { id: commentId } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${containerIdGlobal}/comments?message=${encodeURIComponent( post.message )}&access_token=${accessToken}`, diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts new file mode 100644 index 00000000..cef78db9 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -0,0 +1,198 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider'; + +export class LinkedinPageProvider + extends LinkedinProvider + implements SocialProvider +{ + override identifier = 'linkedin-page'; + override name = 'LinkedIn Page'; + override isBetweenSteps = true; + + override async refreshToken( + refresh_token: string + ): Promise { + const { access_token: accessToken, refresh_token: refreshToken } = await ( + await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token, + client_id: process.env.LINKEDIN_CLIENT_ID!, + client_secret: process.env.LINKEDIN_CLIENT_SECRET!, + }), + }) + ).json(); + + const { vanityName } = await ( + await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { + name, + sub: id, + picture, + } = await ( + await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id, + accessToken, + refreshToken, + name, + picture, + username: vanityName, + }; + } + + override async generateAuthUrl(refresh?: string) { + const state = makeId(6); + const codeVerifier = makeId(30); + const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${ + process.env.LINKEDIN_CLIENT_ID + }&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/linkedin-page${ + refresh ? `?refresh=${refresh}` : '' + }` + )}&state=${state}&scope=${encodeURIComponent( + 'openid profile w_member_social r_basicprofile rw_organization_admin w_organization_social r_organization_social' + )}`; + return { + url, + codeVerifier, + state, + }; + } + + async companies(accessToken: string) { + const { elements } = await ( + await fetch( + 'https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&projection=(elements*(organizationalTarget~(localizedName,vanityName,logoV2(original~:playableStreams))))', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return (elements || []).map((e: any) => ({ + id: e.organizationalTarget.split(':').pop(), + page: e.organizationalTarget.split(':').pop(), + username: e['organizationalTarget~'].vanityName, + name: e['organizationalTarget~'].localizedName, + picture: + e['organizationalTarget~'].logoV2?.['original~']?.elements?.[0] + ?.identifiers?.[0]?.identifier, + })); + } + + async fetchPageInformation(accessToken: string, pageId: string) { + const data = await ( + await fetch( + `https://api.linkedin.com/v2/organizations/${pageId}?projection=(id,localizedName,vanityName,logoV2(original~:playableStreams))`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return { + id: data.id, + name: data.localizedName, + access_token: accessToken, + picture: data?.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0].identifier, + username: data.vanityName, + }; + } + + override async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = new URLSearchParams(); + body.append('grant_type', 'authorization_code'); + body.append('code', params.code); + body.append( + 'redirect_uri', + `${process.env.FRONTEND_URL}/integrations/social/linkedin-page${ + params.refresh ? `?refresh=${params.refresh}` : '' + }` + ); + body.append('client_id', process.env.LINKEDIN_CLIENT_ID!); + body.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET!); + + const { + access_token: accessToken, + expires_in: expiresIn, + refresh_token: refreshToken, + } = await ( + await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + ).json(); + + const { + name, + sub: id, + picture, + } = await ( + await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { vanityName } = await ( + await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id, + accessToken, + refreshToken, + expiresIn, + name, + picture, + username: vanityName, + }; + } + + override async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + return super.post(id, accessToken, postDetails, 'company'); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index 44a4362e..25fe4b88 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -9,15 +9,16 @@ import sharp from 'sharp'; import { lookup } from 'mime-types'; import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch'; import { removeMarkdown } from '@gitroom/helpers/utils/remove.markdown'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -export class LinkedinProvider implements SocialProvider { +export class LinkedinProvider extends SocialAbstract implements SocialProvider { identifier = 'linkedin'; name = 'LinkedIn'; isBetweenSteps = false; async refreshToken(refresh_token: string): Promise { const { access_token: accessToken, refresh_token: refreshToken } = await ( - await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + await this.fetch('https://www.linkedin.com/oauth/v2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -32,7 +33,7 @@ export class LinkedinProvider implements SocialProvider { ).json(); const { vanityName } = await ( - await fetch('https://api.linkedin.com/v2/me', { + await this.fetch('https://api.linkedin.com/v2/me', { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -44,7 +45,7 @@ export class LinkedinProvider implements SocialProvider { sub: id, picture, } = await ( - await fetch('https://api.linkedin.com/v2/userinfo', { + await this.fetch('https://api.linkedin.com/v2/userinfo', { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -102,7 +103,7 @@ export class LinkedinProvider implements SocialProvider { expires_in: expiresIn, refresh_token: refreshToken, } = await ( - await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + await this.fetch('https://www.linkedin.com/oauth/v2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -116,7 +117,7 @@ export class LinkedinProvider implements SocialProvider { sub: id, picture, } = await ( - await fetch('https://api.linkedin.com/v2/userinfo', { + await this.fetch('https://api.linkedin.com/v2/userinfo', { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -124,7 +125,7 @@ export class LinkedinProvider implements SocialProvider { ).json(); const { vanityName } = await ( - await fetch('https://api.linkedin.com/v2/me', { + await this.fetch('https://api.linkedin.com/v2/me', { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -152,7 +153,7 @@ export class LinkedinProvider implements SocialProvider { } const { elements } = await ( - await fetch( + await this.fetch( `https://api.linkedin.com/rest/organizations?q=vanityName&vanityName=${getCompanyVanity[1]}`, { method: 'GET', @@ -174,17 +175,18 @@ export class LinkedinProvider implements SocialProvider { }; } - private async uploadPicture( + protected async uploadPicture( fileName: string, accessToken: string, personId: string, - picture: any + picture: any, + type = 'personal' as 'company' | 'personal' ) { try { const { value: { uploadUrl, image, video, uploadInstructions, ...all }, } = await ( - await fetch( + await this.fetch( `https://api.linkedin.com/rest/${ fileName.indexOf('mp4') > -1 ? 'videos' : 'images' }?action=initializeUpload`, @@ -198,7 +200,10 @@ export class LinkedinProvider implements SocialProvider { }, body: JSON.stringify({ initializeUploadRequest: { - owner: `urn:li:person:${personId}`, + owner: + type === 'personal' + ? `urn:li:person:${personId}` + : `urn:li:organization:${personId}`, ...(fileName.indexOf('mp4') > -1 ? { fileSizeBytes: picture.length, @@ -215,7 +220,7 @@ export class LinkedinProvider implements SocialProvider { const sendUrlRequest = uploadInstructions?.[0]?.uploadUrl || uploadUrl; const finalOutput = video || image; - const upload = await fetch(sendUrlRequest, { + const upload = await this.fetch(sendUrlRequest, { method: 'PUT', headers: { 'X-Restli-Protocol-Version': '2.0.0', @@ -230,7 +235,7 @@ export class LinkedinProvider implements SocialProvider { if (fileName.indexOf('mp4') > -1) { const etag = upload.headers.get('etag'); - const a = await fetch( + const a = await this.fetch( 'https://api.linkedin.com/rest/videos?action=finalizeUpload', { method: 'POST', @@ -260,7 +265,8 @@ export class LinkedinProvider implements SocialProvider { async post( id: string, accessToken: string, - postDetails: PostDetails[] + postDetails: PostDetails[], + type = 'personal' as 'company' | 'personal' ): Promise { const [firstPost, ...restPosts] = postDetails; @@ -281,7 +287,8 @@ export class LinkedinProvider implements SocialProvider { .resize({ width: 1000, }) - .toBuffer() + .toBuffer(), + type ), postId: p.id, }; @@ -300,7 +307,7 @@ export class LinkedinProvider implements SocialProvider { const media_ids = (uploadAll[firstPost.id] || []).filter((f) => f); - const data = await fetch('https://api.linkedin.com/v2/posts', { + const data = await this.fetch('https://api.linkedin.com/v2/posts', { method: 'POST', headers: { 'X-Restli-Protocol-Version': '2.0.0', @@ -308,7 +315,10 @@ export class LinkedinProvider implements SocialProvider { Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ - author: `urn:li:person:${id}`, + author: + type === 'personal' + ? `urn:li:person:${id}` + : `urn:li:organization:${id}`, commentary: removeMarkdown({ text: firstPost.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢'), except: [/@\[(.*?)]\(urn:li:organization:(\d+)\)/g], @@ -350,6 +360,7 @@ export class LinkedinProvider implements SocialProvider { } const topPostId = data.headers.get('x-restli-id')!; + const ids = [ { status: 'posted', @@ -360,7 +371,7 @@ export class LinkedinProvider implements SocialProvider { ]; for (const post of restPosts) { const { object } = await ( - await fetch( + await this.fetch( `https://api.linkedin.com/v2/socialActions/${decodeURIComponent( topPostId )}/comments`, @@ -371,7 +382,7 @@ export class LinkedinProvider implements SocialProvider { Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ - actor: `urn:li:person:${id}`, + actor: type === 'personal' ? `urn:li:person:${id}` : `urn:li:organization:${id}`, object: topPostId, message: { text: removeMarkdown({ diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts index 415e64bb..951beead 100644 --- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts @@ -9,15 +9,16 @@ import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provi import axios from 'axios'; import FormData from 'form-data'; import { timer } from '@gitroom/helpers/utils/timer'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -export class PinterestProvider implements SocialProvider { +export class PinterestProvider extends SocialAbstract implements SocialProvider { identifier = 'pinterest'; name = 'Pinterest'; isBetweenSteps = false; async refreshToken(refreshToken: string): Promise { const { access_token, expires_in } = await ( - await fetch('https://api-sandbox.pinterest.com/v5/oauth/token', { + await this.fetch('https://api.pinterest.com/v5/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -36,7 +37,7 @@ export class PinterestProvider implements SocialProvider { ).json(); const { id, profile_image, username } = await ( - await fetch('https://api-sandbox.pinterest.com/v5/user_account', { + await this.fetch('https://api.pinterest.com/v5/user_account', { method: 'GET', headers: { Authorization: `Bearer ${access_token}`, @@ -78,7 +79,7 @@ export class PinterestProvider implements SocialProvider { refresh: string; }) { const { access_token, refresh_token, expires_in } = await ( - await fetch('https://api-sandbox.pinterest.com/v5/oauth/token', { + await this.fetch('https://api.pinterest.com/v5/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -95,7 +96,7 @@ export class PinterestProvider implements SocialProvider { ).json(); const { id, profile_image, username } = await ( - await fetch('https://api-sandbox.pinterest.com/v5/user_account', { + await this.fetch('https://api.pinterest.com/v5/user_account', { method: 'GET', headers: { Authorization: `Bearer ${access_token}`, @@ -116,7 +117,7 @@ export class PinterestProvider implements SocialProvider { async boards(accessToken: string) { const { items } = await ( - await fetch('https://api-sandbox.pinterest.com/v5/boards', { + await this.fetch('https://api.pinterest.com/v5/boards', { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, @@ -147,7 +148,7 @@ export class PinterestProvider implements SocialProvider { if (findMp4) { const { upload_url, media_id, upload_parameters } = await ( - await fetch('https://api-sandbox.pinterest.com/v5/media', { + await this.fetch('https://api.pinterest.com/v5/media', { method: 'POST', body: JSON.stringify({ media_type: 'video', @@ -180,8 +181,8 @@ export class PinterestProvider implements SocialProvider { while (statusCode !== 'succeeded') { console.log('trying'); const mediafile = await ( - await fetch( - 'https://api-sandbox.pinterest.com/v5/media/' + media_id, + await this.fetch( + 'https://api.pinterest.com/v5/media/' + media_id, { method: 'GET', headers: { @@ -208,7 +209,7 @@ export class PinterestProvider implements SocialProvider { link, ...all } = await ( - await fetch('https://api-sandbox.pinterest.com/v5/pins', { + await this.fetch('https://api.pinterest.com/v5/pins', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index 2a9c0858..db4ffbf2 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -8,8 +8,9 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto'; import { timer } from '@gitroom/helpers/utils/timer'; import { groupBy } from 'lodash'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -export class RedditProvider implements SocialProvider { +export class RedditProvider extends SocialAbstract implements SocialProvider { identifier = 'reddit'; name = 'Reddit'; isBetweenSteps = false; @@ -20,7 +21,7 @@ export class RedditProvider implements SocialProvider { refresh_token: newRefreshToken, expires_in: expiresIn, } = await ( - await fetch('https://www.reddit.com/api/v1/access_token', { + await this.fetch('https://www.reddit.com/api/v1/access_token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -36,7 +37,7 @@ export class RedditProvider implements SocialProvider { ).json(); const { name, id, icon_img } = await ( - await fetch('https://oauth.reddit.com/api/v1/me', { + await this.fetch('https://oauth.reddit.com/api/v1/me', { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -77,7 +78,7 @@ export class RedditProvider implements SocialProvider { refresh_token: refreshToken, expires_in: expiresIn, } = await ( - await fetch('https://www.reddit.com/api/v1/access_token', { + await this.fetch('https://www.reddit.com/api/v1/access_token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -94,7 +95,7 @@ export class RedditProvider implements SocialProvider { ).json(); const { name, id, icon_img } = await ( - await fetch('https://oauth.reddit.com/api/v1/me', { + await this.fetch('https://oauth.reddit.com/api/v1/me', { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -126,7 +127,7 @@ export class RedditProvider implements SocialProvider { data: { id, name, url }, }, } = await ( - await fetch('https://oauth.reddit.com/api/submit', { + await this.fetch('https://oauth.reddit.com/api/submit', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, @@ -181,7 +182,7 @@ export class RedditProvider implements SocialProvider { }, }, } = await ( - await fetch('https://oauth.reddit.com/api/comment', { + await this.fetch('https://oauth.reddit.com/api/comment', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, @@ -226,7 +227,7 @@ export class RedditProvider implements SocialProvider { const { data: { children }, } = await ( - await fetch( + await this.fetch( `https://oauth.reddit.com/subreddits/search?show=public&q=${data.word}&sort=activity&show_users=false&limit=10`, { method: 'GET', @@ -271,7 +272,7 @@ export class RedditProvider implements SocialProvider { const { data: { submission_type, allow_images }, } = await ( - await fetch(`https://oauth.reddit.com/${data.subreddit}/about`, { + await this.fetch(`https://oauth.reddit.com/${data.subreddit}/about`, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, @@ -281,7 +282,7 @@ export class RedditProvider implements SocialProvider { ).json(); const { is_flair_required } = await ( - await fetch( + await this.fetch( `https://oauth.reddit.com/api/v1/${ data.subreddit.split('/r/')[1] }/post_requirements`, @@ -296,7 +297,7 @@ export class RedditProvider implements SocialProvider { ).json(); const newData = await ( - await fetch( + await this.fetch( `https://oauth.reddit.com/${data.subreddit}/api/link_flair_v2`, { method: 'GET', diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index 540cae96..b873958e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -6,8 +6,9 @@ import { } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -export class TiktokProvider implements SocialProvider { +export class TiktokProvider extends SocialAbstract implements SocialProvider { identifier = 'tiktok'; name = 'Tiktok'; isBetweenSteps = false; @@ -73,7 +74,7 @@ export class TiktokProvider implements SocialProvider { refresh?: string; }) { const getAccessToken = await ( - await fetch( + await this.fetch( 'https://graph.facebook.com/v20.0/oauth/access_token' + `?client_id=${process.env.FACEBOOK_APP_ID}` + `&redirect_uri=${encodeURIComponent( @@ -87,7 +88,7 @@ export class TiktokProvider implements SocialProvider { ).json(); const { access_token } = await ( - await fetch( + await this.fetch( 'https://graph.facebook.com/v20.0/oauth/access_token' + '?grant_type=fb_exchange_token' + `&client_id=${process.env.FACEBOOK_APP_ID}` + @@ -119,7 +120,7 @@ export class TiktokProvider implements SocialProvider { data: { url }, }, } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}` ) ).json(); @@ -137,7 +138,7 @@ export class TiktokProvider implements SocialProvider { async pages(accessToken: string) { const { data } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture.type(large)&access_token=${accessToken}` ) ).json(); @@ -155,7 +156,7 @@ export class TiktokProvider implements SocialProvider { data: { url }, }, } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${pageId}?fields=username,access_token,name,picture.type(large)&access_token=${accessToken}` ) ).json(); @@ -180,7 +181,7 @@ export class TiktokProvider implements SocialProvider { let finalUrl = ''; if ((firstPost?.media?.[0]?.path?.indexOf('mp4') || -2) > -1) { const { id: videoId, permalink_url } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/videos?access_token=${accessToken}&fields=id,permalink_url`, { method: 'POST', @@ -204,7 +205,7 @@ export class TiktokProvider implements SocialProvider { : await Promise.all( firstPost.media.map(async (media) => { const { id: photoId } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/photos?access_token=${accessToken}`, { method: 'POST', @@ -228,7 +229,7 @@ export class TiktokProvider implements SocialProvider { permalink_url, ...all } = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${id}/feed?access_token=${accessToken}&fields=id,permalink_url`, { method: 'POST', @@ -251,7 +252,7 @@ export class TiktokProvider implements SocialProvider { const postsArray = []; for (const comment of comments) { const data = await ( - await fetch( + await this.fetch( `https://graph.facebook.com/v20.0/${finalId}/comments?access_token=${accessToken}&fields=id,permalink_url`, { method: 'POST', diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index d866f1bb..2470a3f9 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -9,8 +9,9 @@ import { lookup } from 'mime-types'; import sharp from 'sharp'; import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch'; import removeMd from 'remove-markdown'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -export class XProvider implements SocialProvider { +export class XProvider extends SocialAbstract implements SocialProvider { identifier = 'x'; name = 'X'; isBetweenSteps = false; diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 9fff423c..8ddc3278 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -10,6 +10,7 @@ import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; import * as console from 'node:console'; import axios from 'axios'; import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; const clientAndYoutube = () => { const client = new google.auth.OAuth2({ @@ -33,7 +34,7 @@ const clientAndYoutube = () => { return { client, youtube, oauth2 }; }; -export class YoutubeProvider implements SocialProvider { +export class YoutubeProvider extends SocialAbstract implements SocialProvider { identifier = 'youtube'; name = 'Youtube'; isBetweenSteps = false; diff --git a/libraries/react-shared-libraries/src/helpers/image.with.fallback.tsx b/libraries/react-shared-libraries/src/helpers/image.with.fallback.tsx new file mode 100644 index 00000000..d0b754b3 --- /dev/null +++ b/libraries/react-shared-libraries/src/helpers/image.with.fallback.tsx @@ -0,0 +1,27 @@ +import { FC, useState } from 'react'; +import Image from 'next/image'; + +interface ImageSrc { + src: string; + fallbackSrc: string; + width: number; + height: number; + [key: string]: any; +} + +const ImageWithFallback: FC = (props) => { + const { src, fallbackSrc, ...rest } = props; + const [imgSrc, setImgSrc] = useState(src); + + return ( + { + setImgSrc(fallbackSrc); + }} + /> + ); +}; + +export default ImageWithFallback; \ No newline at end of file