From e4eff984f829c6b6028a1882e32cc83f91e5cf9f Mon Sep 17 00:00:00 2001 From: Nevo David Date: Fri, 24 May 2024 12:07:52 +0700 Subject: [PATCH] feat: providers --- .../src/api/routes/integrations.controller.ts | 23 +- .../src/api/routes/media.controller.ts | 2 +- .../public/icons/platforms/facebook.png | Bin 0 -> 1205 bytes .../integrations/social/[provider]/page.tsx | 42 +-- apps/frontend/src/components/auth/login.tsx | 21 +- .../frontend/src/components/auth/register.tsx | 13 +- .../launches/add.provider.component.tsx | 4 +- .../components/launches/calendar.context.tsx | 5 + .../helpers/pick.platform.component.tsx | 2 +- .../launches/launches.component.tsx | 17 +- .../facebook/facebook.continue.tsx | 88 ++++++ .../instagram/instagram.continue.tsx | 94 +++++++ .../providers/continue-provider/list.tsx | 7 + .../providers/facebook/facebook.provider.tsx | 30 ++- .../instagram/instagram.provider.tsx | 31 ++- .../providers/linkedin/linkedin.provider.tsx | 26 +- .../launches/providers/show.all.providers.tsx | 4 + .../launches/providers/x/x.provider.tsx | 13 +- .../components/layout/continue.provider.tsx | 101 +++++++ .../src/components/layout/layout.settings.tsx | 5 +- .../src/components/layout/top.menu.tsx | 2 +- .../src/components/media/media.component.tsx | 38 ++- .../onboarding/connect.channels.tsx | 52 +++- .../integrations/integration.repository.ts | 58 +++- .../integrations/integration.service.ts | 89 +++++- .../src/database/prisma/schema.prisma | 1 + .../src/integrations/integration.manager.ts | 78 +++--- .../integrations/social/facebook.provider.ts | 254 ++++++++++++++++++ .../integrations/social/instagram.provider.ts | 249 +++++++++++++++-- .../integrations/social/linkedin.provider.ts | 122 ++++++--- .../integrations/social/reddit.provider.ts | 2 + .../social/social.integrations.interface.ts | 1 + .../src/integrations/social/x.provider.ts | 35 +-- .../src/helpers/video.frame.tsx | 9 + .../src/helpers/video.or.image.tsx | 15 ++ 35 files changed, 1317 insertions(+), 216 deletions(-) create mode 100644 apps/frontend/public/icons/platforms/facebook.png create mode 100644 apps/frontend/src/components/launches/providers/continue-provider/facebook/facebook.continue.tsx diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 69dd7030..be30d674 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -44,9 +44,11 @@ export class IntegrationsController { ).map((p) => ({ name: p.name, id: p.id, + internalId: p.internalId, disabled: p.disabled, picture: p.picture, identifier: p.providerIdentifier, + inBetweenSteps: p.inBetweenSteps, type: p.type, })), }; @@ -221,7 +223,8 @@ export class IntegrationsController { accessToken, refreshToken, expiresIn, - username + username, + integrationProvider.isBetweenSteps ); } @@ -233,6 +236,24 @@ export class IntegrationsController { return this._integrationService.disableChannel(org.id, id); } + @Post('/instagram/:id') + async saveInstagram( + @Param('id') id: string, + @Body() body: { pageId: string, id: string }, + @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('/enable') enableChannel( @GetOrgFromRequest() org: Organization, diff --git a/apps/backend/src/api/routes/media.controller.ts b/apps/backend/src/api/routes/media.controller.ts index 851f874a..3b0d371d 100644 --- a/apps/backend/src/api/routes/media.controller.ts +++ b/apps/backend/src/api/routes/media.controller.ts @@ -29,7 +29,7 @@ export class MediaController { new ParseFilePipe({ validators: [ new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), - new FileTypeValidator({ fileType: 'image/*' }), + new FileTypeValidator({ fileType: /^(image\/.+|video\/mp4)$/ }), ], }) ) diff --git a/apps/frontend/public/icons/platforms/facebook.png b/apps/frontend/public/icons/platforms/facebook.png new file mode 100644 index 0000000000000000000000000000000000000000..452103d3a1744908e32aa3f768b99e03adc98b26 GIT binary patch literal 1205 zcmV;m1WNmfP)eqkQ;AjnChy zv)OQhiMTbZP!AzfrBM^smWAh@64qCQzX}L3(X|#uGgdx%tIqbjb+jv(C*cD@fy@d7 z5CqCnD1=6O&*woUM9yO6?Rz0#Y}J8bpC7sgOyJ0g2(`ckeGwwP?N_86x3UT=HDPAPYG`OG;Bp@Dn1rfX#HU*&^u&-ryPBaq>^ zS*Z!poK_L&Js7Ukg!7K}+-ni2LKF(m+?f8c?FVgs*~{2ix%jy}O7)wdd3qUI3FQz0 z0)cQ!c$~D9pLa7>>t#e_m$ZP10wJ?y79`VgmOz{pD?_HiWLA-~zd8d|{LQq}a@^`k z!3TWUO28D@c2!-$Hwf>cvodpPATY7pEejAPspjsk6o>-Y=oW%r9#0^e66{9c zW<#ij4CO7sDdME1g;Igs{_I2(o6m3CiSpW=kncZQzHohQNhT&&t8m=bF{=si+oO#8 z-?Vy7Rvv`C1|-LeirZo*<$-B10_)2czt@bNk{SiJW>d70DpH}&!p=W-Ebiuk2pFr? zY==eg@WAGFDecN{s?cmv{C#K(6|Q}YTmqA{6L@s2ggJY4kHUq!NI!3sfx|Q9plQj= z!?(|cTHSgm?Ce|K`}%z7zIW5F?l1Auvlr{=@J#vmyEe^^4henMQR0+buH}nVTa<_q zH^TYx&vfX%x6o-k*;H`~KkcR*wR9+p8xfYGoPrFbjl#nRBd!S=geNksb`yc+Na$q6 z%8}7;d6iw+*{|yG#O2Bo8J+rENg~R?u&;|NNn~*7;86*YDyxN_HAe1NW|1n!0E2U^ zQB*?2SFmN0QJk=otX&O}3)V9@x#qFn!Y5X}pADR` z_+#iy6@%d0=~217#oX{4YN4BX71<~jCK3fML8TTtS%1fx7VL52j;x~yZq(f|LMw6o zNKq_IB<*Ui9>QkB3AM9tSzmF-3#&`c*V~D5+){QPTkhTzT8Yr?xO>`JR0{YHmMYZY THxi-k00000NkvXXu0mjf4-iPc literal 0 HcmV?d00001 diff --git a/apps/frontend/src/app/(site)/integrations/social/[provider]/page.tsx b/apps/frontend/src/app/(site)/integrations/social/[provider]/page.tsx index ed7e4e64..536ad5b8 100644 --- a/apps/frontend/src/app/(site)/integrations/social/[provider]/page.tsx +++ b/apps/frontend/src/app/(site)/integrations/social/[provider]/page.tsx @@ -1,21 +1,33 @@ export const dynamic = 'force-dynamic'; -import {internalFetch} from "@gitroom/helpers/utils/internal.fetch"; -import {redirect} from "next/navigation"; +import { internalFetch } from '@gitroom/helpers/utils/internal.fetch'; +import { redirect } from 'next/navigation'; -export default async function Page({params: {provider}, searchParams}: {params: {provider: string}, searchParams: any}) { - if (provider === 'x') { - searchParams = { - ...searchParams, - state: searchParams.oauth_token || '', - code: searchParams.oauth_verifier || '' - }; - } +export default async function Page({ + params: { provider }, + searchParams, +}: { + params: { provider: string }; + searchParams: any; +}) { + if (provider === 'x') { + searchParams = { + ...searchParams, + state: searchParams.oauth_token || '', + code: searchParams.oauth_verifier || '', + }; + } + const { id, inBetweenSteps } = await ( await internalFetch(`/integrations/social/${provider}/connect`, { - method: 'POST', - body: JSON.stringify(searchParams) - }); + method: 'POST', + body: JSON.stringify(searchParams), + }) + ).json(); - return redirect(`/launches?added=${provider}`); -} \ No newline at end of file + if (inBetweenSteps) { + return redirect(`/launches?added=${provider}&continue=${id}`); + } + + return redirect(`/launches?added=${provider}`); +} diff --git a/apps/frontend/src/components/auth/login.tsx b/apps/frontend/src/components/auth/login.tsx index 086cb9c2..91c0f3a8 100644 --- a/apps/frontend/src/components/auth/login.tsx +++ b/apps/frontend/src/components/auth/login.tsx @@ -10,6 +10,7 @@ import { classValidatorResolver } from '@hookform/resolvers/class-validator'; import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto'; import { GithubProvider } from '@gitroom/frontend/app/auth/providers/github.provider'; import interClass from '@gitroom/react/helpers/inter.font'; +import { isGeneral } from '@gitroom/react/helpers/is.general'; type Inputs = { email: string; @@ -58,13 +59,19 @@ export function Login() { Sign In - -
-
-
-
OR
-
-
+ {!isGeneral() && ( + <> + +
+
+
+
OR
+
+
+ + )}
; } - return ; + return ( + + ); } export function RegisterAfter({ @@ -110,11 +113,13 @@ export function RegisterAfter({ Sign Up
- {!isAfterProvider && } - {!isAfterProvider && ( + {!isAfterProvider && !isGeneral() && } + {!isAfterProvider && !isGeneral() && (
-
+
OR
diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index 68798e2e..87cf37c9 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -190,7 +190,7 @@ export const AddProviderComponent: FC<{

Social

-
+
{social.map((item) => (

Articles

-
+
{article.map((item) => (
{ (async () => { + if (isGeneral()) { + return []; + } setTrendings(await (await fetch('/posts/predict-trending')).json()); })(); }, []); diff --git a/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx b/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx index 481ca175..8fe11bbe 100644 --- a/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx +++ b/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx @@ -138,7 +138,7 @@ export const PickPlatforms: FC<{ >
- {integrations.map((integration) => + {integrations.filter(f => !f.inBetweenSteps).map((integration) => !props.singleSelect ? (
{ const fetch = useFetch(); + const router = useRouter(); + const [reload, setReload] = useState(false); const load = useCallback(async (path: string) => { return (await (await fetch(path)).json()).integrations; @@ -57,6 +60,10 @@ export const LaunchesComponent = () => { } }, []); + const continueIntegration = useCallback((integration: any) => async () => { + router.push(`/launches?added=${integration.identifier}&continue=${integration.id}`); + }, []); + useEffect(() => { if (typeof window !== 'undefined' && window.opener) { window.close(); @@ -89,6 +96,14 @@ export const LaunchesComponent = () => { integration.disabled && 'opacity-50' )} > + {integration.inBetweenSteps && ( +
+
+ ! +
+
+
+ )} { } : {})} className={clsx( - 'flex-1', + 'flex-1 whitespace-nowrap text-ellipsis overflow-hidden', integration.disabled && 'opacity-50' )} > diff --git a/apps/frontend/src/components/launches/providers/continue-provider/facebook/facebook.continue.tsx b/apps/frontend/src/components/launches/providers/continue-provider/facebook/facebook.continue.tsx new file mode 100644 index 00000000..d4f7879a --- /dev/null +++ b/apps/frontend/src/components/launches/providers/continue-provider/facebook/facebook.continue.tsx @@ -0,0 +1,88 @@ +import { FC, useCallback, useMemo, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import useSWR from 'swr'; +import clsx from 'clsx'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration'; + +export const FacebookContinue: FC<{closeModal: () => 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(() => { + return call.get('pages'); + }, []); + + const setPage = useCallback( + (id: string) => () => { + setSelectedPage(id); + }, + [] + ); + + const { data } = useSWR('load-pages', loadPages, { + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnMount: true, + revalidateOnReconnect: false, + refreshInterval: 0, + }); + + const saveInstagram = useCallback(async () => { + await fetch(`/integrations/facebook/${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 Page:
+
+ {filteredData?.map( + (p: { + id: string; + username: string; + name: string; + picture: { data: { url: string } }; + }) => ( +
+
+ profile +
+
{p.name}
+
+ ) + )} +
+
+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/launches/providers/continue-provider/instagram/instagram.continue.tsx b/apps/frontend/src/components/launches/providers/continue-provider/instagram/instagram.continue.tsx index e69de29b..71a7d1ce 100644 --- a/apps/frontend/src/components/launches/providers/continue-provider/instagram/instagram.continue.tsx +++ b/apps/frontend/src/components/launches/providers/continue-provider/instagram/instagram.continue.tsx @@ -0,0 +1,94 @@ +import { FC, useCallback, useMemo, useState } from 'react'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import useSWR from 'swr'; +import clsx from 'clsx'; +import { Button } from '@gitroom/react/form/button'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration'; + +export const InstagramContinue: FC<{ + closeModal: () => 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(() => { + return call.get('pages'); + }, []); + + 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 saveInstagram = useCallback(async () => { + await fetch(`/integrations/instagram/${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 Instagram Account:
+
+ {filteredData?.map( + (p: { + id: string; + pageId: string; + username: string; + name: string; + picture: { data: { url: 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 e69de29b..4c9f59d7 100644 --- a/apps/frontend/src/components/launches/providers/continue-provider/list.tsx +++ b/apps/frontend/src/components/launches/providers/continue-provider/list.tsx @@ -0,0 +1,7 @@ +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'; + +export const continueProviderList = { + instagram: InstagramContinue, + facebook: FacebookContinue, +} \ No newline at end of file diff --git a/apps/frontend/src/components/launches/providers/facebook/facebook.provider.tsx b/apps/frontend/src/components/launches/providers/facebook/facebook.provider.tsx index a1e9c079..0eb8d336 100644 --- a/apps/frontend/src/components/launches/providers/facebook/facebook.provider.tsx +++ b/apps/frontend/src/components/launches/providers/facebook/facebook.provider.tsx @@ -3,9 +3,13 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/hi import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration'; import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting'; import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; -import {afterLinkedinCompanyPreventRemove, linkedinCompanyPreventRemove} from "@gitroom/helpers/utils/linkedin.company.prevent.remove"; +import { + afterLinkedinCompanyPreventRemove, + linkedinCompanyPreventRemove, +} from '@gitroom/helpers/utils/linkedin.company.prevent.remove'; +import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; -const LinkedinPreview: FC = (props) => { +const FacebookPreview: FC = (props) => { const { value: topValue, integration } = useIntegration(); const mediaDir = useMediaDirectory(); const newValues = useFormatting(topValue, { @@ -43,7 +47,10 @@ const LinkedinPreview: FC = (props) => {
-
+        
 
         {!!firstPost?.images?.length && (
           
@@ -54,10 +61,7 @@ const LinkedinPreview: FC = (props) => { className="flex-1" target="_blank" > - + ))}
@@ -89,10 +93,12 @@ const LinkedinPreview: FC = (props) => { href={mediaDir.set(image.path)} target="_blank" > - +
+ +
))}
@@ -104,4 +110,4 @@ const LinkedinPreview: FC = (props) => { ); }; -export default withProvider(null, LinkedinPreview); +export default withProvider(null, FacebookPreview); diff --git a/apps/frontend/src/components/launches/providers/instagram/instagram.provider.tsx b/apps/frontend/src/components/launches/providers/instagram/instagram.provider.tsx index 759b8e88..4d2a0106 100644 --- a/apps/frontend/src/components/launches/providers/instagram/instagram.provider.tsx +++ b/apps/frontend/src/components/launches/providers/instagram/instagram.provider.tsx @@ -3,9 +3,13 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/hi import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration'; import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting'; import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; -import {afterLinkedinCompanyPreventRemove, linkedinCompanyPreventRemove} from "@gitroom/helpers/utils/linkedin.company.prevent.remove"; +import { + afterLinkedinCompanyPreventRemove, + linkedinCompanyPreventRemove, +} from '@gitroom/helpers/utils/linkedin.company.prevent.remove'; +import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; -const FacebookPreview: FC = (props) => { +const InstagramPreview: FC = (props) => { const { value: topValue, integration } = useIntegration(); const mediaDir = useMediaDirectory(); const newValues = useFormatting(topValue, { @@ -43,8 +47,6 @@ const FacebookPreview: FC = (props) => {
-
-
         {!!firstPost?.images?.length && (
           
{firstPost.images.map((image, index) => ( @@ -54,14 +56,15 @@ const FacebookPreview: FC = (props) => { className="flex-1" target="_blank" > - + ))}
)} +
       
{morePosts.map((p, index) => (
@@ -89,10 +92,12 @@ const FacebookPreview: FC = (props) => { href={mediaDir.set(image.path)} target="_blank" > - +
+ +
))}
@@ -104,4 +109,4 @@ const FacebookPreview: FC = (props) => { ); }; -export default withProvider(null, FacebookPreview); +export default withProvider(null, InstagramPreview); diff --git a/apps/frontend/src/components/launches/providers/linkedin/linkedin.provider.tsx b/apps/frontend/src/components/launches/providers/linkedin/linkedin.provider.tsx index a1e9c079..8aa305a8 100644 --- a/apps/frontend/src/components/launches/providers/linkedin/linkedin.provider.tsx +++ b/apps/frontend/src/components/launches/providers/linkedin/linkedin.provider.tsx @@ -3,7 +3,11 @@ import { withProvider } from '@gitroom/frontend/components/launches/providers/hi import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration'; import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting'; import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; -import {afterLinkedinCompanyPreventRemove, linkedinCompanyPreventRemove} from "@gitroom/helpers/utils/linkedin.company.prevent.remove"; +import { + afterLinkedinCompanyPreventRemove, + linkedinCompanyPreventRemove, +} from '@gitroom/helpers/utils/linkedin.company.prevent.remove'; +import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; const LinkedinPreview: FC = (props) => { const { value: topValue, integration } = useIntegration(); @@ -43,7 +47,10 @@ const LinkedinPreview: FC = (props) => {
-
+        
 
         {!!firstPost?.images?.length && (
           
@@ -54,10 +61,7 @@ const LinkedinPreview: FC = (props) => { className="flex-1" target="_blank" > - + ))}
@@ -89,10 +93,12 @@ const LinkedinPreview: FC = (props) => { href={mediaDir.set(image.path)} target="_blank" > - +
+ +
))}
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 56dc86fc..85a62f5c 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -6,6 +6,8 @@ import LinkedinProvider from "@gitroom/frontend/components/launches/providers/li import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit/reddit.provider"; import MediumProvider from "@gitroom/frontend/components/launches/providers/medium/medium.provider"; import HashnodeProvider from "@gitroom/frontend/components/launches/providers/hashnode/hashnode.provider"; +import FacebookProvider from '@gitroom/frontend/components/launches/providers/facebook/facebook.provider'; +import InstagramProvider from '@gitroom/frontend/components/launches/providers/instagram/instagram.provider'; export const Providers = [ {identifier: 'devto', component: DevtoProvider}, @@ -14,6 +16,8 @@ export const Providers = [ {identifier: 'reddit', component: RedditProvider}, {identifier: 'medium', component: MediumProvider}, {identifier: 'hashnode', component: HashnodeProvider}, + {identifier: 'facebook', component: FacebookProvider}, + {identifier: 'instagram', component: InstagramProvider}, ]; diff --git a/apps/frontend/src/components/launches/providers/x/x.provider.tsx b/apps/frontend/src/components/launches/providers/x/x.provider.tsx index 0481cc16..ed2f65b0 100644 --- a/apps/frontend/src/components/launches/providers/x/x.provider.tsx +++ b/apps/frontend/src/components/launches/providers/x/x.provider.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration'; import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting'; import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; +import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; const chirp = localFont({ src: [ @@ -82,11 +83,13 @@ const XPreview: FC = (props) => { {!!value?.images?.length && (
{value.images.map((image, index) => ( - - + + ))}
diff --git a/apps/frontend/src/components/layout/continue.provider.tsx b/apps/frontend/src/components/layout/continue.provider.tsx index e69de29b..be656d91 100644 --- a/apps/frontend/src/components/layout/continue.provider.tsx +++ b/apps/frontend/src/components/layout/continue.provider.tsx @@ -0,0 +1,101 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import { continueProviderList } from '@gitroom/frontend/components/launches/providers/continue-provider/list'; +import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration'; +import dayjs from 'dayjs'; +import useSWR, { useSWRConfig } from 'swr'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; + +export const Null: FC<{ closeModal: () => void; existingId: string[] }> = () => + null; +export const ContinueProvider: FC = () => { + const { mutate } = useSWRConfig(); + const fetch = useFetch(); + const searchParams = useSearchParams(); + const added = searchParams.get('added'); + const continueId = searchParams.get('continue'); + const router = useRouter(); + + const load = useCallback(async (path: string) => { + const list = (await (await fetch(path)).json()).integrations; + return list; + }, []); + + const { data: integrations } = useSWR('/integrations/list', load, { + fallbackData: [], + }); + + const closeModal = useCallback(() => { + mutate('/integrations/list'); + const url = new URL(window.location.href); + url.searchParams.delete('added'); + url.searchParams.delete('continue'); + router.push(url.toString()); + }, []); + + const Provider = useMemo(() => { + if (!added) { + return Null; + } + return continueProviderList[added as keyof typeof continueProviderList]; + }, [added]); + + if (!added || !continueId || !integrations) { + return null; + } + + return ( +
+
e.stopPropagation()} + > +
+ + +
+ + p.internalId)} /> + +
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/layout/layout.settings.tsx b/apps/frontend/src/components/layout/layout.settings.tsx index 0c8b0a5c..c1e63613 100644 --- a/apps/frontend/src/components/layout/layout.settings.tsx +++ b/apps/frontend/src/components/layout/layout.settings.tsx @@ -24,6 +24,8 @@ import { ShowLinkedinCompany } from '@gitroom/frontend/components/launches/helpe import { SettingsComponent } from '@gitroom/frontend/components/layout/settings.component'; import { Onboarding } from '@gitroom/frontend/components/onboarding/onboarding'; import { Support } from '@gitroom/frontend/components/layout/support'; +import { ContinueProvider } from '@gitroom/frontend/components/layout/continue.provider'; +import { isGeneral } from '@gitroom/react/helpers/is.general'; dayjs.extend(utc); dayjs.extend(weekOfYear); @@ -54,13 +56,14 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => { +
Logo
-
Gitroom
+
{isGeneral() ? 'Postiz' : 'Gitroom'}
{user?.orgId ? :
}
diff --git a/apps/frontend/src/components/layout/top.menu.tsx b/apps/frontend/src/components/layout/top.menu.tsx index e53fbeb2..5dac13bc 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -20,7 +20,7 @@ export const menuItems = [ ] : []), { - name: 'Launches', + name: isGeneral() ? 'Calendar' : 'Launches', icon: 'launches', path: '/launches', }, diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index 7adb8013..aebed26b 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -11,6 +11,7 @@ import EventEmitter from 'events'; import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; import clsx from 'clsx'; import interClass from '@gitroom/react/helpers/inter.font'; +import { VideoFrame } from '@gitroom/react/helpers/video.frame'; const showModalEmitter = new EventEmitter(); export const ShowMediaBoxModal: FC = () => { @@ -139,10 +140,12 @@ export const MediaBox: FC<{ -
@@ -295,11 +304,18 @@ export const MultiMediaComponent: FC<{ currentMedia.map((media, index) => ( <>
- window.open(mediaDirectory.set(media.path))} - /> + > + {media.path.indexOf('mp4') > -1 ? ( + + ) : ( + + )} +
{ const fetch = useFetch(); + const router = useRouter(); const [identifier, setIdentifier] = useState(undefined); + const [popup, setPopups] = useState(undefined); const getIntegrations = useCallback(async () => { return (await fetch('/integrations')).json(); @@ -30,7 +40,9 @@ export const ConnectChannels: FC = () => { ); const load = useCallback(async (path: string) => { - return (await (await fetch(path)).json()).integrations; + const list = (await (await fetch(path)).json()).integrations; + setPopups(list.map((p: any) => p.id)); + return list; }, []); const { data: integrations, mutate } = useSWR('/integrations/list', load, { @@ -54,6 +66,21 @@ export const ConnectChannels: FC = () => { ); }, [integrations]); + useEffect(() => { + if (sortedIntegrations.length === 0 || !popup) { + return; + } + + const betweenSteps = sortedIntegrations.find((p) => p.inBetweenSteps); + if (betweenSteps && popup.indexOf(betweenSteps.id) === -1) { + const url = new URL(window.location.href); + url.searchParams.append('added', betweenSteps.identifier); + url.searchParams.append('continue', betweenSteps.id); + router.push(url.toString()); + setPopups([...popup, betweenSteps.id]); + } + }, [sortedIntegrations, popup]); + const update = useCallback(async (shouldReload: boolean) => { if (shouldReload) { setReload(true); @@ -65,6 +92,16 @@ export const ConnectChannels: FC = () => { } }, []); + const continueIntegration = useCallback( + (integration: any) => async () => { + const url = new URL(window.location.href); + url.searchParams.append('added', integration.identifier); + url.searchParams.append('continue', integration.id); + router.push(url.toString()); + }, + [] + ); + const finishUpdate = useCallback(() => { setIdentifier(undefined); update(true); @@ -153,6 +190,17 @@ export const ConnectChannels: FC = () => { integration.disabled && 'opacity-50' )} > + {integration.inBetweenSteps && ( +
+
+ ! +
+
+
+ )} ) {} + updateIntegration(id: string, params: Partial) { + return this._integration.model.integration.update({ + where: { + id, + }, + data: { + ...params, + }, + }); + } + createOrUpdateIntegration( org: string, name: string, @@ -20,7 +33,8 @@ export class IntegrationRepository { token: string, refreshToken = '', expiresIn = 999999999, - username?: string + username?: string, + isBetweenSteps = false ) { return this._integration.model.integration.upsert({ where: { @@ -36,6 +50,7 @@ export class IntegrationRepository { token, profile: username, picture, + inBetweenSteps: isBetweenSteps, refreshToken, ...(expiresIn ? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) } @@ -47,6 +62,7 @@ export class IntegrationRepository { type: type as any, name, providerIdentifier: provider, + inBetweenSteps: isBetweenSteps, token, picture, profile: username, @@ -67,6 +83,7 @@ export class IntegrationRepository { tokenExpiration: { lte: dayjs().add(1, 'day').toDate(), }, + inBetweenSteps: false, deletedAt: null, }, }); @@ -81,7 +98,12 @@ export class IntegrationRepository { }); } - async getIntegrationForOrder(id: string, order: string, user: string, org: string) { + async getIntegrationForOrder( + id: string, + order: string, + user: string, + org: string + ) { console.log(id, order, user, org); const integration = await this._posts.model.post.findFirst({ where: { @@ -90,12 +112,12 @@ export class IntegrationRepository { id: order, messageGroup: { OR: [ - {sellerId: user}, - {buyerId: user}, - {buyerOrganizationId: org}, - ] - } - } + { sellerId: user }, + { buyerId: user }, + { buyerOrganizationId: org }, + ], + }, + }, }, select: { integration: { @@ -103,10 +125,11 @@ export class IntegrationRepository { id: true, name: true, picture: true, + inBetweenSteps: true, providerIdentifier: true, }, - } - } + }, + }, }); return integration?.integration; @@ -170,6 +193,21 @@ export class IntegrationRepository { }); } + async checkForDeletedOnceAndUpdate(org: string, page: string) { + return this._integration.model.integration.updateMany({ + where: { + organizationId: org, + internalId: page, + deletedAt: { + not: null, + }, + }, + data: { + internalId: makeId(10), + } + }); + } + async disableIntegrations(org: string, totalChannels: number) { const getChannels = await this._integration.model.integration.findMany({ where: { 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 1bc4e746..bf464f9c 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -1,6 +1,8 @@ -import {HttpException, HttpStatus, Injectable} from '@nestjs/common'; +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'; @Injectable() export class IntegrationService { @@ -18,7 +20,8 @@ export class IntegrationService { token: string, refreshToken = '', expiresIn?: number, - username?: string + username?: string, + isBetweenSteps = false ) { return this._integrationRepository.createOrUpdateIntegration( org, @@ -30,7 +33,8 @@ export class IntegrationService { token, refreshToken, expiresIn, - username + username, + isBetweenSteps ); } @@ -39,7 +43,12 @@ export class IntegrationService { } getIntegrationForOrder(id: string, order: string, user: string, org: string) { - return this._integrationRepository.getIntegrationForOrder(id, order, user, org); + return this._integrationRepository.getIntegrationForOrder( + id, + order, + user, + org + ); } getIntegrationById(org: string, id: string) { @@ -86,9 +95,15 @@ export class IntegrationService { } async deleteChannel(org: string, id: string) { - const isTherePosts = await this._integrationRepository.countPostsForChannel(org, id); + const isTherePosts = await this._integrationRepository.countPostsForChannel( + org, + id + ); if (isTherePosts) { - throw new HttpException('There are posts for this channel', HttpStatus.NOT_ACCEPTABLE); + throw new HttpException( + 'There are posts for this channel', + HttpStatus.NOT_ACCEPTABLE + ); } return this._integrationRepository.deleteChannel(org, id); @@ -97,4 +112,66 @@ export class IntegrationService { async disableIntegrations(org: string, totalChannels: number) { return this._integrationRepository.disableIntegrations(org, totalChannels); } + + async checkForDeletedOnceAndUpdate(org: string, page: string) { + return this._integrationRepository.checkForDeletedOnceAndUpdate(org, page); + } + + async saveInstagram(org: string, id: string, data: { pageId: string, id: string }) { + const getIntegration = await this._integrationRepository.getIntegrationById( + org, + id + ); + if (getIntegration && !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!, + 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, + }); + + 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, + }); + + return { success: true }; + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 1a71c00a..e45014fc 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -200,6 +200,7 @@ model Integration { createdAt DateTime @default(now()) updatedAt DateTime? @updatedAt orderItems OrderItems[] + inBetweenSteps Boolean @default(false) @@index([updatedAt]) @@index([deletedAt]) diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index 17adf2ee..7ff7f066 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -1,43 +1,53 @@ -import {Injectable} from "@nestjs/common"; -import {XProvider} from "@gitroom/nestjs-libraries/integrations/social/x.provider"; -import {SocialProvider} from "@gitroom/nestjs-libraries/integrations/social/social.integrations.interface"; -import {LinkedinProvider} from "@gitroom/nestjs-libraries/integrations/social/linkedin.provider"; -import {RedditProvider} from "@gitroom/nestjs-libraries/integrations/social/reddit.provider"; -import {DevToProvider} from "@gitroom/nestjs-libraries/integrations/article/dev.to.provider"; -import {HashnodeProvider} from "@gitroom/nestjs-libraries/integrations/article/hashnode.provider"; -import {MediumProvider} from "@gitroom/nestjs-libraries/integrations/article/medium.provider"; -import {ArticleProvider} from "@gitroom/nestjs-libraries/integrations/article/article.integrations.interface"; +import { Injectable } from '@nestjs/common'; +import { XProvider } from '@gitroom/nestjs-libraries/integrations/social/x.provider'; +import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider'; +import { RedditProvider } from '@gitroom/nestjs-libraries/integrations/social/reddit.provider'; +import { DevToProvider } from '@gitroom/nestjs-libraries/integrations/article/dev.to.provider'; +import { HashnodeProvider } from '@gitroom/nestjs-libraries/integrations/article/hashnode.provider'; +import { MediumProvider } from '@gitroom/nestjs-libraries/integrations/article/medium.provider'; +import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface'; +import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider'; +import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider'; const socialIntegrationList = [ - new XProvider(), - new LinkedinProvider(), - new RedditProvider() + new XProvider(), + new LinkedinProvider(), + new RedditProvider(), + new FacebookProvider(), + new InstagramProvider(), ]; const articleIntegrationList = [ - new DevToProvider(), - new HashnodeProvider(), - new MediumProvider() + new DevToProvider(), + new HashnodeProvider(), + new MediumProvider(), ]; @Injectable() export class IntegrationManager { - getAllIntegrations() { - return { - social: socialIntegrationList.map(p => ({name: p.name, identifier: p.identifier})), - article: articleIntegrationList.map(p => ({name: p.name, identifier: p.identifier})), - }; - } - getAllowedSocialsIntegrations() { - return socialIntegrationList.map(p => p.identifier); - } - getSocialIntegration(integration: string): SocialProvider { - return socialIntegrationList.find(i => i.identifier === integration)!; - } - getAllowedArticlesIntegrations() { - return articleIntegrationList.map(p => p.identifier); - } - getArticlesIntegration(integration: string): ArticleProvider { - return articleIntegrationList.find(i => i.identifier === integration)!; - } -} \ No newline at end of file + getAllIntegrations() { + return { + social: socialIntegrationList.map((p) => ({ + name: p.name, + identifier: p.identifier, + })), + article: articleIntegrationList.map((p) => ({ + name: p.name, + identifier: p.identifier, + })), + }; + } + getAllowedSocialsIntegrations() { + return socialIntegrationList.map((p) => p.identifier); + } + getSocialIntegration(integration: string): SocialProvider { + return socialIntegrationList.find((i) => i.identifier === integration)!; + } + getAllowedArticlesIntegrations() { + return articleIntegrationList.map((p) => p.identifier); + } + getArticlesIntegration(integration: string): ArticleProvider { + return articleIntegrationList.find((i) => i.identifier === integration)!; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index e69de29b..a4f9cf4f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -0,0 +1,254 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; + +export class FacebookProvider implements SocialProvider { + identifier = 'facebook'; + name = 'Facebook Page'; + isBetweenSteps = true; + + async refreshToken(refresh_token: string): Promise { + const { access_token, expires_in, ...all } = await ( + await fetch( + 'https://graph.facebook.com/v19.0/oauth/access_token' + + '?grant_type=fb_exchange_token' + + `&client_id=${process.env.FACEBOOK_APP_ID}` + + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + + `&fb_exchange_token=${refresh_token}` + ) + ).json(); + + const { + id, + name, + picture: { + data: { url }, + }, + } = await ( + await fetch( + `https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}` + ) + ).json(); + + return { + id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: expires_in, + picture: url, + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: + 'https://www.facebook.com/v19.0/dialog/oauth' + + `?client_id=${process.env.FACEBOOK_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/facebook` + )}` + + `&state=${state}` + + '&scope=pages_show_list,business_management,pages_manage_posts,publish_video,pages_manage_engagement,pages_read_engagement', + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { code: string; codeVerifier: string }) { + const getAccessToken = await ( + await fetch( + 'https://graph.facebook.com/v19.0/oauth/access_token' + + `?client_id=${process.env.FACEBOOK_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/facebook` + )}` + + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + + `&code=${params.code}` + ) + ).json(); + + const { access_token, expires_in, ...all } = await ( + await fetch( + 'https://graph.facebook.com/v19.0/oauth/access_token' + + '?grant_type=fb_exchange_token' + + `&client_id=${process.env.FACEBOOK_APP_ID}` + + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + + `&fb_exchange_token=${getAccessToken.access_token}` + ) + ).json(); + + const { + id, + name, + picture: { + data: { url }, + }, + } = await ( + await fetch( + `https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}` + ) + ).json(); + + return { + id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: expires_in, + picture: url, + username: '', + }; + } + + async pages(accessToken: string) { + const { data } = await ( + await fetch( + `https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture.type(large)&access_token=${accessToken}` + ) + ).json(); + + return data; + } + + async fetchPageInformation(accessToken: string, pageId: string) { + const { + id, + name, + access_token, + picture: { + data: { url }, + }, + } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${pageId}?fields=access_token,name,picture.type(large)&access_token=${accessToken}` + ) + ).json(); + + return { + id, + name, + access_token, + picture: url, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost, ...comments] = postDetails; + + let finalId = ''; + let finalUrl = ''; + if ((firstPost?.media?.[0]?.path?.indexOf('mp4') || 0) > -1) { + const { id: videoId, permalink_url } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/videos?access_token=${accessToken}&fields=id,permalink_url`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_url: firstPost?.media?.[0]?.path!, + description: firstPost.message, + published: true, + }), + } + ) + ).json(); + + finalUrl = permalink_url; + finalId = videoId; + } else { + const uploadPhotos = !firstPost?.media?.length + ? [] + : await Promise.all( + firstPost.media.map(async (media) => { + const { id: photoId } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/photos?access_token=${accessToken}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: media.url, + published: false, + }), + } + ) + ).json(); + + return { media_fbid: photoId }; + }) + ); + + const { id: postId, permalink_url } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/feed?access_token=${accessToken}&fields=id,permalink_url`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...(uploadPhotos?.length ? { attached_media: uploadPhotos } : {}), + message: firstPost.message, + published: true, + }), + } + ) + ).json(); + + finalUrl = permalink_url; + finalId = postId; + } + + const postsArray = []; + for (const comment of comments) { + const data = await ( + await fetch( + `https://graph.facebook.com/v20.0/${finalId}/comments?access_token=${accessToken}&fields=id,permalink_url`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...(comment.media?.length + ? { attachment_url: comment.media[0].url } + : {}), + message: comment.message, + }), + } + ) + ).json(); + + postsArray.push({ + id: comment.id, + postId: data.id, + releaseURL: data.permalink_url, + status: 'success', + }); + } + return [ + { + id: firstPost.id, + postId: finalId, + releaseURL: finalUrl, + status: 'success', + }, + ...postsArray, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index e591f9c2..44a6f96c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -5,14 +5,17 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { timer } from '@gitroom/helpers/utils/timer'; + +export class InstagramProvider implements SocialProvider { + identifier = 'instagram'; + name = 'Instagram'; + isBetweenSteps = true; -export class FacebookProvider implements SocialProvider { - identifier = 'facebook'; - name = 'Facebook'; async refreshToken(refresh_token: string): Promise { const { access_token, expires_in, ...all } = await ( await fetch( - 'https://graph.facebook.com/v19.0/oauth/access_token' + + 'https://graph.facebook.com/v20.0/oauth/access_token' + '?grant_type=fb_exchange_token' + `&client_id=${process.env.FACEBOOK_APP_ID}` + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + @@ -21,19 +24,29 @@ export class FacebookProvider implements SocialProvider { ).json(); const { - id, - name, - picture: { - data: { url }, + data: { + id, + name, + picture: { + data: { url }, + }, }, } = await ( await fetch( - `https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}` + `https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture&access_token=${access_token}` + ) + ).json(); + + const { + instagram_business_account: { id: instagramId }, + } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}?fields=instagram_business_account&access_token=${access_token}` ) ).json(); return { - id, + id: instagramId, name, accessToken: access_token, refreshToken: access_token, @@ -47,14 +60,15 @@ export class FacebookProvider implements SocialProvider { const state = makeId(6); return { url: - 'https://www.facebook.com/v19.0/dialog/oauth' + + 'https://www.facebook.com/v20.0/dialog/oauth' + `?client_id=${process.env.FACEBOOK_APP_ID}` + `&redirect_uri=${encodeURIComponent( - `${process.env.FRONTEND_URL}/integrations/social/facebook` + `${process.env.FRONTEND_URL}/integrations/social/instagram` )}` + `&state=${state}` + - '&scope=email,public_profile', - // '&scope=email,public_profile,pages_manage_posts,pages_read_engagement,publish_to_groups,groups_access_member_info', + `&scope=${encodeURIComponent( + 'instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments' + )}`, codeVerifier: makeId(10), state, }; @@ -63,10 +77,10 @@ export class FacebookProvider implements SocialProvider { async authenticate(params: { code: string; codeVerifier: string }) { const getAccessToken = await ( await fetch( - 'https://graph.facebook.com/v19.0/oauth/access_token' + + 'https://graph.facebook.com/v20.0/oauth/access_token' + `?client_id=${process.env.FACEBOOK_APP_ID}` + `&redirect_uri=${encodeURIComponent( - `${process.env.FRONTEND_URL}/integrations/social/facebook` + `${process.env.FRONTEND_URL}/integrations/social/instagram` )}` + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + `&code=${params.code}` @@ -75,7 +89,7 @@ export class FacebookProvider implements SocialProvider { const { access_token, expires_in, ...all } = await ( await fetch( - 'https://graph.facebook.com/v19.0/oauth/access_token' + + 'https://graph.facebook.com/v20.0/oauth/access_token' + '?grant_type=fb_exchange_token' + `&client_id=${process.env.FACEBOOK_APP_ID}` + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + @@ -91,7 +105,7 @@ export class FacebookProvider implements SocialProvider { }, } = await ( await fetch( - `https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}` + `https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}` ) ).json(); @@ -106,11 +120,208 @@ export class FacebookProvider implements SocialProvider { }; } + async pages(accessToken: string) { + const { data } = await ( + await 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(); + + const onlyConnectedAccounts = await Promise.all( + data + .filter((f: any) => f.instagram_business_account) + .map(async (p: any) => { + return { + pageId: p.id, + ...(await ( + await fetch( + `https://graph.facebook.com/v20.0/${p.instagram_business_account.id}?fields=name,profile_picture_url&access_token=${accessToken}&limit=500` + ) + ).json()), + id: p.instagram_business_account.id, + }; + }) + ); + + return onlyConnectedAccounts.map((p: any) => ({ + pageId: p.pageId, + id: p.id, + name: p.name, + picture: { data: { url: p.profile_picture_url } }, + })); + } + + async fetchPageInformation( + accessToken: string, + data: { pageId: string; id: string } + ) { + const { access_token } = await ( + await 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 } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${data.id}?fields=name,profile_picture_url&access_token=${accessToken}` + ) + ).json(); + + return { + id, + name, + picture: profile_picture_url, + access_token, + }; + } + async post( id: string, accessToken: string, postDetails: PostDetails[] ): Promise { - return []; + const [firstPost, ...theRest] = postDetails; + + const medias = await Promise.all( + firstPost?.media?.map(async (m) => { + const caption = + firstPost.media?.length === 1 ? `&caption=${firstPost.message}` : ``; + const isCarousel = + (firstPost?.media?.length || 0) > 1 ? `&is_carousel_item=true` : ``; + const mediaType = + m.path.indexOf('.mp4') > -1 + ? firstPost?.media?.length === 1 + ? `video_url=${m.url}&media_type=REELS` + : `video_url=${m.url}&media_type=VIDEO` + : `image_url=${m.url}`; + const { id: photoId } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/media?${mediaType}${caption}${isCarousel}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + let status = 'IN_PROGRESS'; + while (status === 'IN_PROGRESS') { + const { status_code } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${photoId}?access_token=${accessToken}&fields=status_code` + ) + ).json(); + await timer(3000); + status = status_code; + } + + return photoId; + }) || [] + ); + + const arr = []; + + let containerIdGlobal = ''; + let linkGlobal = ''; + if (medias.length === 1) { + const { id: mediaId, ...all } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/media_publish?creation_id=${medias[0]}&access_token=${accessToken}&field=id`, + { + method: 'POST', + } + ) + ).json(); + + console.log(all); + + containerIdGlobal = mediaId; + + const { permalink } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}` + ) + ).json(); + + arr.push({ + id: firstPost.id, + postId: mediaId, + releaseURL: permalink, + status: 'success', + }); + + linkGlobal = permalink; + } else { + const { id: containerId, ...all3 } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/media?caption=${encodeURIComponent( + firstPost?.message + )}&media_type=CAROUSEL&children=${encodeURIComponent( + medias.join(',') + )}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + let status = 'IN_PROGRESS'; + while (status === 'IN_PROGRESS') { + const { status_code } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${containerId}?fields=status_code&access_token=${accessToken}` + ) + ).json(); + await timer(3000); + status = status_code; + } + + const { id: mediaId, ...all4 } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/media_publish?creation_id=${containerId}&access_token=${accessToken}&field=id`, + { + method: 'POST', + } + ) + ).json(); + + containerIdGlobal = mediaId; + + const { permalink } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}` + ) + ).json(); + + arr.push({ + id: firstPost.id, + postId: mediaId, + releaseURL: permalink, + status: 'success', + }); + + linkGlobal = permalink; + } + + for (const post of theRest) { + const { id: commentId, ...all } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${containerIdGlobal}/comments?message=${encodeURIComponent( + post.message + )}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + arr.push({ + id: firstPost.id, + postId: commentId, + releaseURL: linkGlobal, + status: 'success', + }); + } + + return arr; } } diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index 074be276..22a701d8 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -13,6 +13,8 @@ import { removeMarkdown } from '@gitroom/helpers/utils/remove.markdown'; export class LinkedinProvider 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', { @@ -165,43 +167,86 @@ export class LinkedinProvider implements SocialProvider { } private async uploadPicture( + fileName: string, accessToken: string, personId: string, picture: any ) { - const { - value: { uploadUrl, image }, - } = await ( - await fetch( - 'https://api.linkedin.com/rest/images?action=initializeUpload', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Restli-Protocol-Version': '2.0.0', - 'LinkedIn-Version': '202402', - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ - initializeUploadRequest: { - owner: `urn:li:person:${personId}`, + try { + const { + value: { uploadUrl, image, video, uploadInstructions, ...all }, + } = await ( + await fetch( + `https://api.linkedin.com/rest/${ + fileName.indexOf('mp4') > -1 ? 'videos' : 'images' + }?action=initializeUpload`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202402', + Authorization: `Bearer ${accessToken}`, }, - }), - } - ) - ).json(); + body: JSON.stringify({ + initializeUploadRequest: { + owner: `urn:li:person:${personId}`, + ...(fileName.indexOf('mp4') > -1 + ? { + fileSizeBytes: picture.length, + uploadCaptions: false, + uploadThumbnail: false, + } + : {}), + }, + }), + } + ) + ).json(); - await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'X-Restli-Protocol-Version': '2.0.0', - 'LinkedIn-Version': '202402', - Authorization: `Bearer ${accessToken}`, - }, - body: picture, - }); + const sendUrlRequest = uploadInstructions?.[0]?.uploadUrl || uploadUrl; + const finalOutput = video || image; - return image; + const upload = await fetch(sendUrlRequest, { + method: 'PUT', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202402', + Authorization: `Bearer ${accessToken}`, + ...(fileName.indexOf('mp4') > -1 + ? { 'Content-Type': 'application/octet-stream' } + : {}), + }, + body: picture, + }); + + if (fileName.indexOf('mp4') > -1) { + const etag = upload.headers.get('etag'); + const a = await fetch( + 'https://api.linkedin.com/rest/videos?action=finalizeUpload', + { + method: 'POST', + body: JSON.stringify({ + finalizeUploadRequest: { + video, + uploadToken: '', + uploadedPartIds: [etag], + }, + }), + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202402', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + } + ); + } + + return finalOutput; + } catch (err: any) { + throw 'eerr'; + } } async post( @@ -217,15 +262,18 @@ export class LinkedinProvider implements SocialProvider { p?.media?.flatMap(async (m) => { return { id: await this.uploadPicture( + m.path, accessToken, id, - await sharp(await readOrFetch(m.path), { - animated: lookup(m.path) === 'image/gif', - }) - .resize({ - width: 1000, - }) - .toBuffer() + m.path.indexOf('mp4') > -1 + ? Buffer.from(await readOrFetch(m.path)) + : await sharp(await readOrFetch(m.path), { + animated: lookup(m.path) === 'image/gif', + }) + .resize({ + width: 1000, + }) + .toBuffer() ), postId: p.id, }; diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index d5208446..2a9c0858 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -12,6 +12,8 @@ import { groupBy } from 'lodash'; export class RedditProvider implements SocialProvider { identifier = 'reddit'; name = 'Reddit'; + isBetweenSteps = false; + async refreshToken(refreshToken: string): Promise { const { access_token: accessToken, diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index 2af4df58..711a609d 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -53,4 +53,5 @@ export type MediaContent = { export interface SocialProvider extends IAuthenticator, ISocialMediaIntegration { identifier: string; name: string; + isBetweenSteps: boolean; } \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index e0dbe480..bf1a2e17 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -13,6 +13,8 @@ import removeMd from 'remove-markdown'; export class XProvider implements SocialProvider { identifier = 'x'; name = 'X'; + isBetweenSteps = false; + async refreshToken(refreshToken: string): Promise { const startingClient = new TwitterApi({ clientId: process.env.TWITTER_CLIENT_ID!, @@ -47,8 +49,6 @@ export class XProvider implements SocialProvider { async generateAuthUrl() { const client = new TwitterApi({ - // clientId: process.env.TWITTER_CLIENT_ID!, - // clientSecret: process.env.TWITTER_CLIENT_SECRET!, appKey: process.env.X_API_KEY!, appSecret: process.env.X_API_SECRET!, }); @@ -128,14 +128,16 @@ export class XProvider implements SocialProvider { p?.media?.flatMap(async (m) => { return { id: await client.v1.uploadMedia( - await sharp(await readOrFetch(m.path), { - animated: lookup(m.path) === 'image/gif', - }) - .resize({ - width: 1000, - }) - .gif() - .toBuffer(), + m.path.indexOf('mp4') > -1 + ? Buffer.from(await readOrFetch(m.path)) + : await sharp(await readOrFetch(m.path), { + animated: lookup(m.path) === 'image/gif', + }) + .resize({ + width: 1000, + }) + .gif() + .toBuffer(), { mimeType: lookup(m.path) || '', } @@ -183,17 +185,4 @@ export class XProvider implements SocialProvider { status: 'posted', })); } - - // async analytics(accessToken: string) { - // const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); - // const client = new TwitterApi({ - // appKey: process.env.X_API_KEY!, - // appSecret: process.env.X_API_SECRET!, - // accessToken: accessTokenSplit, - // accessSecret: accessSecretSplit, - // }); - // const { - // data: { username }, - // } = await client.v2; - // } } diff --git a/libraries/react-shared-libraries/src/helpers/video.frame.tsx b/libraries/react-shared-libraries/src/helpers/video.frame.tsx index e69de29b..a7275660 100644 --- a/libraries/react-shared-libraries/src/helpers/video.frame.tsx +++ b/libraries/react-shared-libraries/src/helpers/video.frame.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { FC } from 'react'; + +export const VideoFrame: FC<{ url: string }> = (props) => { + const { url } = props; + + return ; +}; diff --git a/libraries/react-shared-libraries/src/helpers/video.or.image.tsx b/libraries/react-shared-libraries/src/helpers/video.or.image.tsx index e69de29b..1b2e90c7 100644 --- a/libraries/react-shared-libraries/src/helpers/video.or.image.tsx +++ b/libraries/react-shared-libraries/src/helpers/video.or.image.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; + +export const VideoOrImage: FC<{ src: string; autoplay: boolean }> = (props) => { + const { src, autoplay } = props; + if (src.indexOf('mp4') > -1) { + return