diff --git a/apps/backend/src/api/routes/analytics.controller.ts b/apps/backend/src/api/routes/analytics.controller.ts index 82a4f83c..ab0571f6 100644 --- a/apps/backend/src/api/routes/analytics.controller.ts +++ b/apps/backend/src/api/routes/analytics.controller.ts @@ -1,42 +1,83 @@ -import {Body, Controller, Get, Post} from '@nestjs/common'; -import {Organization} from "@prisma/client"; -import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request"; -import {StarsService} from "@gitroom/nestjs-libraries/database/prisma/stars/stars.service"; -import dayjs from "dayjs"; -import {StarsListDto} from "@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto"; -import {ApiTags} from "@nestjs/swagger"; +import { Body, Controller, Get, Inject, Param, Post, Query } from '@nestjs/common'; +import { Organization } from '@prisma/client'; +import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; +import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service'; +import dayjs from 'dayjs'; +import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto'; +import { ApiTags } from '@nestjs/swagger'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; @ApiTags('Analytics') @Controller('/analytics') export class AnalyticsController { - constructor( - private _starsService: StarsService - ) { - } - @Get('/') - async getStars( - @GetOrgFromRequest() org: Organization - ) { - return this._starsService.getStars(org.id); + constructor( + private _starsService: StarsService, + private _integrationService: IntegrationService, + private _integrationManager: IntegrationManager + ) {} + @Get('/') + async getStars(@GetOrgFromRequest() org: Organization) { + return this._starsService.getStars(org.id); + } + + @Get('/trending') + async getTrending() { + const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00')); + const last = todayTrending.isAfter(dayjs()) + ? todayTrending.subtract(1, 'day') + : todayTrending; + const nextTrending = last.add(1, 'day'); + + return { + last: last.format('YYYY-MM-DD HH:mm:ss'), + predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'), + }; + } + + @Post('/stars') + async getStarsFilter( + @GetOrgFromRequest() org: Organization, + @Body() starsFilter: StarsListDto + ) { + return { + stars: await this._starsService.getStarsFilter(org.id, starsFilter), + }; + } + + @Get('/:integration') + async getIntegration( + @GetOrgFromRequest() org: Organization, + @Param('integration') integration: string, + @Query('date') date: string + ) { + const getIntegration = await this._integrationService.getIntegrationById( + org.id, + integration + ); + + if (!getIntegration) { + throw new Error('Invalid integration'); } - @Get('/trending') - async getTrending() { - const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00')); - const last = todayTrending.isAfter(dayjs()) ? todayTrending.subtract(1, 'day') : todayTrending; - const nextTrending = last.add(1, 'day'); + if (getIntegration.type === 'social') { + const integrationProvider = this._integrationManager.getSocialIntegration( + getIntegration.providerIdentifier + ); - return { - last: last.format('YYYY-MM-DD HH:mm:ss'), - predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'), - } - } + const getIntegrationData = await ioRedis.get(`integration:${org.id}:${integration}:${date}`); + if (getIntegrationData) { + return JSON.parse(getIntegrationData) + } - @Post('/stars') - async getStarsFilter( - @GetOrgFromRequest() org: Organization, - @Body() starsFilter: StarsListDto - ) { - return {stars: await this._starsService.getStarsFilter(org.id, starsFilter)}; + if (integrationProvider.analytics) { + const loadAnalytics = await integrationProvider.analytics(getIntegration.internalId, getIntegration.token, +date); + await ioRedis.set(`integration:${org.id}:${integration}:${date}`, JSON.stringify(loadAnalytics), 'EX', !process.env.NODE_ENV || process.env.NODE_ENV === 'development' ? 1 : 3600); + return loadAnalytics; + } + + return {}; } + } } diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 2fb340df..d6f86401 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -199,6 +199,8 @@ export class IntegrationsController { throw new Error('Invalid state'); } + await ioRedis.del(`login:${body.state}`); + const integrationProvider = this._integrationManager.getSocialIntegration(integration); const { diff --git a/apps/frontend/src/app/(site)/analytics/page.tsx b/apps/frontend/src/app/(site)/analytics/page.tsx index 16d5d8da..ac117078 100644 --- a/apps/frontend/src/app/(site)/analytics/page.tsx +++ b/apps/frontend/src/app/(site)/analytics/page.tsx @@ -2,16 +2,19 @@ import { isGeneral } from '@gitroom/react/helpers/is.general'; export const dynamic = 'force-dynamic'; -import {AnalyticsComponent} from "@gitroom/frontend/components/analytics/analytics.component"; -import {Metadata} from "next"; +import { AnalyticsComponent } from '@gitroom/frontend/components/analytics/analytics.component'; +import { Metadata } from 'next'; +import { PlatformAnalytics } from '@gitroom/frontend/components/platform-analytics/platform.analytics'; export const metadata: Metadata = { title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Analytics`, description: '', -} +}; export default async function Index() { return ( - + <> + {isGeneral() ? : } + ); } diff --git a/apps/frontend/src/components/analytics/chart-social.tsx b/apps/frontend/src/components/analytics/chart-social.tsx new file mode 100644 index 00000000..f51db426 --- /dev/null +++ b/apps/frontend/src/components/analytics/chart-social.tsx @@ -0,0 +1,84 @@ +'use client'; +import { FC, useEffect, useMemo, useRef } from 'react'; +import DrawChart from 'chart.js/auto'; +import { TotalList } from '@gitroom/frontend/components/analytics/stars.and.forks.interface'; +import dayjs from 'dayjs'; +import { chunk } from 'lodash'; + +function mergeDataPoints(data: TotalList[], numPoints: number): TotalList[] { + const res = chunk(data, Math.ceil(data.length / numPoints)); + return res.map((row) => { + return { + date: `${row[0].date} - ${row?.at(-1)?.date}`, + total: row.reduce((acc, curr) => acc + curr.total, 0), + }; + }); +} + +export const ChartSocial: FC<{ data: TotalList[] }> = (props) => { + const { data } = props; + const list = useMemo(() => { + return mergeDataPoints(data, 7); + }, [data]); + + const ref = useRef(null); + const chart = useRef(null); + useEffect(() => { + const gradient = ref.current + .getContext('2d') + .createLinearGradient(0, 0, 0, ref.current.height); + gradient.addColorStop(0, 'rgb(20,101,6)'); // Start color with some transparency + gradient.addColorStop(1, 'rgb(9, 11, 19, 1)'); + chart.current = new DrawChart(ref.current!, { + type: 'line', + options: { + maintainAspectRatio: false, + responsive: true, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + y: { + beginAtZero: true, + display: false, + }, + x: { + display: false, + ticks: { + stepSize: 10, + maxTicksLimit: 7, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + data: { + labels: list.map((row) => row.date), + datasets: [ + { + borderColor: '#fff', + // @ts-ignore + label: 'Total', + backgroundColor: gradient, + fill: true, + // @ts-ignore + data: list.map((row) => row.total), + }, + ], + }, + }); + return () => { + chart?.current?.destroy(); + }; + }, []); + return ; +}; diff --git a/apps/frontend/src/components/analytics/stars.and.forks.interface.ts b/apps/frontend/src/components/analytics/stars.and.forks.interface.ts index 6ae14c1e..d795ad69 100644 --- a/apps/frontend/src/components/analytics/stars.and.forks.interface.ts +++ b/apps/frontend/src/components/analytics/stars.and.forks.interface.ts @@ -3,6 +3,11 @@ export interface StarsList { date: string; } +export interface TotalList { + total: number; + date: string; +} + export interface ForksList { totalForks: number; date: string; diff --git a/apps/frontend/src/components/layout/support.tsx b/apps/frontend/src/components/layout/support.tsx index 7a777e91..ffec314e 100644 --- a/apps/frontend/src/components/layout/support.tsx +++ b/apps/frontend/src/components/layout/support.tsx @@ -1,8 +1,28 @@ 'use client'; export const Support = () => { - if (!process.env.NEXT_PUBLIC_DISCORD_SUPPORT) return null + if (!process.env.NEXT_PUBLIC_DISCORD_SUPPORT) return null; return ( -
window.open(process.env.NEXT_PUBLIC_DISCORD_SUPPORT)}>Discord Support
- ) -} \ No newline at end of file +
window.open(process.env.NEXT_PUBLIC_DISCORD_SUPPORT)} + > +
+ + + +
+
Discord Support
+
+ ); +}; diff --git a/apps/frontend/src/components/layout/top.menu.tsx b/apps/frontend/src/components/layout/top.menu.tsx index 5dac13bc..216bc481 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -24,6 +24,15 @@ export const menuItems = [ icon: 'launches', path: '/launches', }, + ...(general + ? [ + { + name: 'Analytics', + icon: 'analytics', + path: '/analytics', + }, + ] + : []), ...(!general ? [ { diff --git a/apps/frontend/src/components/platform-analytics/platform.analytics.tsx b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx new file mode 100644 index 00000000..184664a9 --- /dev/null +++ b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx @@ -0,0 +1,181 @@ +'use client'; + +import useSWR from 'swr'; +import { useCallback, useMemo, useState } from 'react'; +import { orderBy } from 'lodash'; +import clsx from 'clsx'; +import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback'; +import Image from 'next/image'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { RenderAnalytics } from '@gitroom/frontend/components/platform-analytics/render.analytics'; +import { Select } from '@gitroom/react/form/select'; + +const allowedIntegrations = [ + 'facebook', + 'instagram', + 'linkedin-page', + 'tiktok', + 'youtube', + 'pinterest' +]; + +export const PlatformAnalytics = () => { + const fetch = useFetch(); + const [current, setCurrent] = useState(0); + const [key, setKey] = useState(7); + + const load = useCallback(async () => { + const int = (await (await fetch('/integrations/list')).json()).integrations; + return int.filter((f: any) => allowedIntegrations.includes(f.identifier)); + }, []); + + const { data } = useSWR('analytics-list', load, { + fallbackData: [], + }); + + const sortedIntegrations = useMemo(() => { + return orderBy( + data, + ['type', 'disabled', 'identifier'], + ['desc', 'asc', 'asc'] + ); + }, [data]); + + const currentIntegration = useMemo(() => { + return sortedIntegrations[current]; + }, [current, sortedIntegrations]); + + const options = useMemo(() => { + if (!currentIntegration) { + return []; + } + const arr = []; + if ( + ['facebook', 'instagram', 'linkedin-page', 'pinterest', 'youtube'].indexOf( + currentIntegration.identifier + ) !== -1 + ) { + arr.push({ + key: 7, + value: '7 Days', + }); + } + + if ( + ['facebook', 'instagram', 'linkedin-page', 'pinterest', 'youtube'].indexOf( + currentIntegration.identifier + ) !== -1 + ) { + arr.push({ + key: 30, + value: '30 Days', + }); + } + + if ( + ['facebook', 'linkedin-page', 'pinterest', 'youtube'].indexOf(currentIntegration.identifier) !== + -1 + ) { + arr.push({ + key: 90, + value: '90 Days', + }); + } + + return arr; + }, [currentIntegration]); + + const keys = useMemo(() => { + if (!currentIntegration) { + return 7; + } + if (options.find((p) => p.key === key)) { + return key; + } + + return options[0]?.key; + }, [key, currentIntegration]); + + return ( +
+
+
+
Channels
+ {sortedIntegrations.map((integration, index) => ( +
setCurrent(index)} + className={clsx( + 'flex gap-[8px] items-center', + currentIntegration.id !== integration.id && + 'opacity-20 hover:opacity-100 cursor-pointer' + )} + > +
+ {(integration.inBetweenSteps || integration.refreshNeeded) && ( +
+
+ ! +
+
+
+ )} + + {integration.identifier} +
+
+ {integration.name} +
+
+ ))} +
+
+
+
+ +
+
+ {!!keys && !!currentIntegration && ( + + )} +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/platform-analytics/render.analytics.tsx b/apps/frontend/src/components/platform-analytics/render.analytics.tsx new file mode 100644 index 00000000..18401d77 --- /dev/null +++ b/apps/frontend/src/components/platform-analytics/render.analytics.tsx @@ -0,0 +1,77 @@ +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { Integration } from '@prisma/client'; +import useSWR from 'swr'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { ChartSocial } from '@gitroom/frontend/components/analytics/chart-social'; +import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; + +export const RenderAnalytics: FC<{ integration: Integration; date: number }> = ( + props +) => { + const { integration, date } = props; + const [loading, setLoading] = useState(true); + + const fetch = useFetch(); + + const load = useCallback(async () => { + setLoading(true); + const load = ( + await fetch(`/analytics/${integration.id}?date=${date}`) + ).json(); + setLoading(false); + return load; + }, [integration, date]); + + const { data } = useSWR(`/analytics-${integration?.id}-${date}`, load, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + refreshWhenOffline: false, + revalidateOnMount: true, + }); + + const total = useMemo(() => { + return data?.map( + (p: any) => { + const value = (p?.data.reduce((acc: number, curr: any) => acc + curr.total, 0) || 0) / + (p.average ? p.data.length : 1); + + if (p.average) { + return value.toFixed(2) + '%'; + } + + return value; + } + ); + }, [data]); + + if (loading) { + return ( + <> + + + ); + } + + return ( +
+ {data?.map((p: any, index: number) => ( +
+
+
+
{p.label}
+
+
+
+ +
+
+
{total[index]}
+
+
+ ))} +
+ ); +}; diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts index 624468a4..52cd0c7f 100644 --- a/apps/frontend/src/middleware.ts +++ b/apps/frontend/src/middleware.ts @@ -87,12 +87,6 @@ export async function middleware(request: NextRequest) { return redirect; } - if (isGeneral() && (nextUrl.pathname.indexOf('/analytics') > -1 || nextUrl.pathname.indexOf('/settings') > -1)) { - return NextResponse.redirect( - new URL('/launches', nextUrl.href) - ); - } - if (nextUrl.pathname === '/') { return NextResponse.redirect( new URL(isGeneral() ? '/launches' : `/analytics`, nextUrl.href) diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index c0e93cda..f001409c 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -16,7 +16,7 @@ import { DribbbleProvider } from '@gitroom/nestjs-libraries/integrations/social/ import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider'; const socialIntegrationList = [ - new XProvider(), + ...(process.env.IS_GENERAL !== 'true' ? [new XProvider()] : []), new LinkedinProvider(), new LinkedinPageProvider(), new RedditProvider(), diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts index e892e9ae..0d76c8b2 100644 --- a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts @@ -1,4 +1,5 @@ import { + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, @@ -14,7 +15,7 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab export class DribbbleProvider extends SocialAbstract implements SocialProvider { identifier = 'dribbble'; - name = 'Dribbbble'; + name = 'Dribbble'; isBetweenSteps = false; async refreshToken(refreshToken: string): Promise { @@ -67,10 +68,12 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider { }) ).json(); - return teams?.map((team: any) => ({ - id: team.id, - name: team.name, - })) || []; + return ( + teams?.map((team: any) => ({ + id: team.id, + name: team.name, + })) || [] + ); } async generateAuthUrl(refresh?: string) { @@ -268,4 +271,12 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider { return []; } } + + analytics( + id: string, + accessToken: string, + date: number + ): Promise { + return Promise.resolve([]); + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index d8824364..9ba02834 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -1,4 +1,5 @@ import { + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, @@ -32,7 +33,9 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { '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${refresh ? `?refresh=${refresh}` : ''}` + `${process.env.FRONTEND_URL}/integrations/social/facebook${ + refresh ? `?refresh=${refresh}` : '' + }` )}` + `&state=${state}` + '&scope=pages_show_list,business_management,pages_manage_posts,pages_manage_engagement,pages_read_engagement', @@ -259,4 +262,37 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { ...postsArray, ]; } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const until = dayjs().format('YYYY-MM-DD'); + const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + const { data } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/insights?metric=page_impressions_unique,page_posts_impressions_unique,page_post_engagements,page_daily_follows,page_video_views&access_token=${accessToken}&period=day&since=${since}&until=${until}` + ) + ).json(); + + return data.map((d: any) => ({ + label: + d.name === 'page_impressions_unique' + ? 'Page Impressions' + : d.name === 'page_post_engagements' + ? 'Posts Engagement' + : d.name === 'page_daily_follows' + ? 'Page followers' + : d.name === 'page_video_views' + ? 'Videos views' + : 'Posts Impressions', + percentageChange: 5, + data: d.values.map((v: any) => ({ + total: v.value, + date: dayjs(v.end_time).format('YYYY-MM-DD'), + })), + })); + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index 37b6be8f..f6492edf 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -1,4 +1,5 @@ import { + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, @@ -8,8 +9,12 @@ 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'; +import { chunk } from 'lodash'; -export class InstagramProvider extends SocialAbstract implements SocialProvider { +export class InstagramProvider + extends SocialAbstract + implements SocialProvider +{ identifier = 'instagram'; name = 'Instagram'; isBetweenSteps = true; @@ -39,7 +44,7 @@ export class InstagramProvider extends SocialAbstract implements SocialProvider )}` + `&state=${state}` + `&scope=${encodeURIComponent( - 'instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments' + 'instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments,instagram_manage_insights' )}`, codeVerifier: makeId(10), state, @@ -88,7 +93,9 @@ export class InstagramProvider extends SocialAbstract implements SocialProvider ).json(); if (params.refresh) { - const findPage = (await this.pages(access_token)).find(p => p.id === params.refresh); + const findPage = (await this.pages(access_token)).find( + (p) => p.id === params.refresh + ); const information = await this.fetchPageInformation(access_token, { id: params.refresh, pageId: findPage?.pageId!, @@ -319,4 +326,30 @@ export class InstagramProvider extends SocialAbstract implements SocialProvider return arr; } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const until = dayjs().format('YYYY-MM-DD'); + const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + const { data, ...all } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${id}/insights?metric=follower_count,impressions,reach,profile_views&access_token=${accessToken}&period=day&since=${since}&until=${until}` + ) + ).json(); + + console.log(all); + + return data?.map((d: any) => ({ + label: d.title, + percentageChange: 5, + data: d.values.map((v: any) => ({ + total: v.value, + date: dayjs(v.end_time).format('YYYY-MM-DD'), + })), + })) || []; + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts index cef78db9..8a732567 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -1,4 +1,5 @@ import { + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, @@ -6,6 +7,9 @@ import { } 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'; +import { number, string } from 'yup'; +import dayjs from 'dayjs'; +import { writeFileSync } from 'fs'; export class LinkedinPageProvider extends LinkedinProvider @@ -121,7 +125,8 @@ export class LinkedinPageProvider id: data.id, name: data.localizedName, access_token: accessToken, - picture: data?.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0].identifier, + picture: + data?.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0].identifier, username: data.vanityName, }; } @@ -195,4 +200,332 @@ export class LinkedinPageProvider ): Promise { return super.post(id, accessToken, postDetails, 'company'); } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const endDate = dayjs().unix() * 1000; + const startDate = dayjs().subtract(date, 'days').unix() * 1000; + + const { elements }: { elements: Root[]; paging: any } = await ( + await fetch( + `https://api.linkedin.com/rest/organizationPageStatistics?q=organization&organization=${encodeURIComponent( + `urn:li:organization:${id}` + )}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Linkedin-Version': '202405', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + ) + ).json(); + + const { elements: elements2 }: { elements: Root[]; paging: any } = await ( + await fetch( + `https://api.linkedin.com/rest/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent( + `urn:li:organization:${id}` + )}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Linkedin-Version': '202405', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + ) + ).json(); + + const { elements: elements3 }: { elements: Root[]; paging: any } = await ( + await fetch( + `https://api.linkedin.com/rest/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent( + `urn:li:organization:${id}` + )}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Linkedin-Version': '202405', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + ) + ).json(); + + const analytics = [...elements2, ...elements, ...elements3].reduce( + (all, current) => { + if ( + typeof current?.totalPageStatistics?.views?.allPageViews + ?.pageViews !== 'undefined' + ) { + all['Page Views'].push({ + total: current.totalPageStatistics.views.allPageViews.pageViews, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + if ( + typeof current?.followerGains?.organicFollowerGain !== 'undefined' + ) { + all['Organic Followers'].push({ + total: current?.followerGains?.organicFollowerGain, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + if (typeof current?.followerGains?.paidFollowerGain !== 'undefined') { + all['Paid Followers'].push({ + total: current?.followerGains?.paidFollowerGain, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + if (typeof current?.totalShareStatistics !== 'undefined') { + all['Clicks'].push({ + total: current?.totalShareStatistics.clickCount, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + + all['Shares'].push({ + total: current?.totalShareStatistics.shareCount, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + + all['Engagement'].push({ + total: current?.totalShareStatistics.engagement, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + + all['Comments'].push({ + total: current?.totalShareStatistics.commentCount, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + return all; + }, + { + 'Page Views': [] as any[], + Clicks: [] as any[], + Shares: [] as any[], + Engagement: [] as any[], + Comments: [] as any[], + 'Organic Followers': [] as any[], + 'Paid Followers': [] as any[], + } + ); + + return Object.keys(analytics).map((key) => ({ + label: key, + data: analytics[ + key as 'Page Views' | 'Organic Followers' | 'Paid Followers' + ], + percentageChange: 5, + })); + } +} + +export interface Root { + pageStatisticsByIndustryV2: any[]; + pageStatisticsBySeniority: any[]; + organization: string; + pageStatisticsByGeoCountry: any[]; + pageStatisticsByTargetedContent: any[]; + totalPageStatistics: TotalPageStatistics; + pageStatisticsByStaffCountRange: any[]; + pageStatisticsByFunction: any[]; + pageStatisticsByGeo: any[]; + followerGains: { organicFollowerGain: number; paidFollowerGain: number }; + timeRange: TimeRange; + totalShareStatistics: { + uniqueImpressionsCount: number; + shareCount: number; + engagement: number; + clickCount: number; + likeCount: number; + impressionCount: number; + commentCount: number; + }; +} + +export interface TotalPageStatistics { + clicks: Clicks; + views: Views; +} + +export interface Clicks { + mobileCustomButtonClickCounts: any[]; + desktopCustomButtonClickCounts: any[]; +} + +export interface Views { + mobileProductsPageViews: MobileProductsPageViews; + allDesktopPageViews: AllDesktopPageViews; + insightsPageViews: InsightsPageViews; + mobileAboutPageViews: MobileAboutPageViews; + allMobilePageViews: AllMobilePageViews; + productsPageViews: ProductsPageViews; + desktopProductsPageViews: DesktopProductsPageViews; + jobsPageViews: JobsPageViews; + peoplePageViews: PeoplePageViews; + overviewPageViews: OverviewPageViews; + mobileOverviewPageViews: MobileOverviewPageViews; + lifeAtPageViews: LifeAtPageViews; + desktopOverviewPageViews: DesktopOverviewPageViews; + mobileCareersPageViews: MobileCareersPageViews; + allPageViews: AllPageViews; + careersPageViews: CareersPageViews; + mobileJobsPageViews: MobileJobsPageViews; + mobileLifeAtPageViews: MobileLifeAtPageViews; + desktopJobsPageViews: DesktopJobsPageViews; + desktopPeoplePageViews: DesktopPeoplePageViews; + aboutPageViews: AboutPageViews; + desktopAboutPageViews: DesktopAboutPageViews; + mobilePeoplePageViews: MobilePeoplePageViews; + desktopCareersPageViews: DesktopCareersPageViews; + desktopInsightsPageViews: DesktopInsightsPageViews; + desktopLifeAtPageViews: DesktopLifeAtPageViews; + mobileInsightsPageViews: MobileInsightsPageViews; +} + +export interface MobileProductsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AllDesktopPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface InsightsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileAboutPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AllMobilePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface ProductsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopProductsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface JobsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface PeoplePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface OverviewPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileOverviewPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface LifeAtPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopOverviewPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileCareersPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AllPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface CareersPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileJobsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileLifeAtPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopJobsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopPeoplePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AboutPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopAboutPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobilePeoplePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopCareersPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopInsightsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopLifeAtPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileInsightsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface TimeRange { + start: number; + end: number; } diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index 25fe4b88..51b18117 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -1,8 +1,5 @@ import { - AuthTokenDetails, - PostDetails, - PostResponse, - SocialProvider, + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import sharp from 'sharp'; @@ -10,6 +7,7 @@ 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'; +import { number, string } from 'yup'; export class LinkedinProvider extends SocialAbstract implements SocialProvider { identifier = 'linkedin'; diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts index 951beead..b09bc655 100644 --- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts @@ -1,4 +1,5 @@ import { + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, @@ -10,8 +11,12 @@ 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'; +import dayjs from 'dayjs'; -export class PinterestProvider extends SocialAbstract implements SocialProvider { +export class PinterestProvider + extends SocialAbstract + implements SocialProvider +{ identifier = 'pinterest'; name = 'Pinterest'; isBetweenSteps = false; @@ -181,15 +186,12 @@ export class PinterestProvider extends SocialAbstract implements SocialProvider while (statusCode !== 'succeeded') { console.log('trying'); const mediafile = await ( - await this.fetch( - 'https://api.pinterest.com/v5/media/' + media_id, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) + await this.fetch('https://api.pinterest.com/v5/media/' + media_id, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) ).json(); await timer(3000); @@ -261,4 +263,68 @@ export class PinterestProvider extends SocialAbstract implements SocialProvider return []; } } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const until = dayjs().format('YYYY-MM-DD'); + const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + const { + all: { daily_metrics }, + } = await ( + await this.fetch( + `https://api.pinterest.com/v5/user_account/analytics?start_date=${since}&end_date=${until}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + ).json(); + + return daily_metrics.reduce( + (acc: any, item: any) => { + if (typeof item.metrics.PIN_CLICK_RATE !== 'undefined') { + acc[0].data.push({ + date: item.date, + total: item.metrics.PIN_CLICK_RATE, + }); + + acc[1].data.push({ + date: item.date, + total: item.metrics.IMPRESSION, + }); + + acc[2].data.push({ + date: item.date, + total: item.metrics.PIN_CLICK, + }); + + acc[3].data.push({ + date: item.date, + total: item.metrics.ENGAGEMENT, + }); + + acc[4].data.push({ + date: item.date, + total: item.metrics.SAVE, + }); + } + + return acc; + }, + [ + { label: 'Pin click rate', data: [] as any[] }, + { label: 'Impressions', data: [] as any[] }, + { label: 'Pin Clicks', data: [] as any[] }, + { label: 'Engagement', data: [] as any[] }, + { label: 'Saves', data: [] as any[] }, + ] + ); + } } 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 cd2b8ebc..7449ceb0 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -1,57 +1,74 @@ export interface IAuthenticator { - authenticate(params: {code: string, codeVerifier: string, refresh?: string}): Promise; - refreshToken(refreshToken: string): Promise; - generateAuthUrl(refresh?: string): Promise; + authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }): Promise; + refreshToken(refreshToken: string): Promise; + generateAuthUrl(refresh?: string): Promise; + analytics?(id: string, accessToken: string, date: number): Promise; +} + +export interface AnalyticsData { + label: string; + data: Array<{ total: string; date: string }>; + percentageChange: number; } export type GenerateAuthUrlResponse = { - url: string, - codeVerifier: string, - state: string -} + url: string; + codeVerifier: string; + state: string; +}; export type AuthTokenDetails = { - id: string; - name: string; - accessToken: string; // The obtained access token - refreshToken?: string; // The refresh token, if applicable - expiresIn?: number; // The duration in seconds for which the access token is valid - picture?: string; - username: string; + id: string; + name: string; + accessToken: string; // The obtained access token + refreshToken?: string; // The refresh token, if applicable + expiresIn?: number; // The duration in seconds for which the access token is valid + picture?: string; + username: string; }; export interface ISocialMediaIntegration { - post(id: string, accessToken: string, postDetails: PostDetails[]): Promise; // Schedules a new post + post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise; // Schedules a new post } export type PostResponse = { - id: string; // The db internal id of the post - postId: string; // The ID of the scheduled post returned by the platform - releaseURL: string; // The URL of the post on the platform - status: string; // Status of the operation or initial post status + id: string; // The db internal id of the post + postId: string; // The ID of the scheduled post returned by the platform + releaseURL: string; // The URL of the post on the platform + status: string; // Status of the operation or initial post status }; export type PostDetails = { - id: string; - message: string; - settings: T; - media?: MediaContent[]; - poll?: PollDetails; + id: string; + message: string; + settings: T; + media?: MediaContent[]; + poll?: PollDetails; }; export type PollDetails = { - options: string[]; // Array of poll options - duration: number; // Duration in hours for which the poll will be active -} - -export type MediaContent = { - type: 'image' | 'video'; // Type of the media content - url: string; // URL of the media file, if it's already hosted somewhere - path: string; + options: string[]; // Array of poll options + duration: number; // Duration in hours for which the poll will be active }; -export interface SocialProvider extends IAuthenticator, ISocialMediaIntegration { - identifier: string; - name: string; - isBetweenSteps: boolean; -} \ No newline at end of file +export type MediaContent = { + type: 'image' | 'video'; // Type of the media content + url: string; // URL of the media file, if it's already hosted somewhere + path: string; +}; + +export interface SocialProvider + extends IAuthenticator, + ISocialMediaIntegration { + identifier: string; + name: string; + isBetweenSteps: boolean; +} diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index b873958e..de634367 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -73,103 +73,18 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { codeVerifier: string; refresh?: string; }) { - const getAccessToken = await ( - await this.fetch( - '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${ - params.refresh ? `?refresh=${params.refresh}` : '' - }` - )}` + - `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + - `&code=${params.code}` - ) - ).json(); - - const { access_token } = await ( - await this.fetch( - '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}` + - `&fb_exchange_token=${getAccessToken.access_token}&fields=access_token,expires_in` - ) - ).json(); - - if (params.refresh) { - const information = await this.fetchPageInformation( - access_token, - params.refresh - ); - return { - id: information.id, - name: information.name, - accessToken: information.access_token, - refreshToken: information.access_token, - expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(), - picture: information.picture, - username: information.username, - }; - } - - const { - id, - name, - picture: { - data: { url }, - }, - } = await ( - await this.fetch( - `https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}` - ) - ).json(); - + console.log(params); return { - id, - name, - accessToken: access_token, - refreshToken: access_token, + id: '', + name: '', + accessToken: '', + refreshToken: '', expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(), - picture: url, + picture: '', username: '', }; } - async pages(accessToken: string) { - const { data } = await ( - await this.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, - username, - picture: { - data: { url }, - }, - } = await ( - await this.fetch( - `https://graph.facebook.com/v20.0/${pageId}?fields=username,access_token,name,picture.type(large)&access_token=${accessToken}` - ) - ).json(); - - return { - id, - name, - access_token, - picture: url, - username, - }; - } - async post( id: string, accessToken: string, diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 8ddc3278..a6c1b89e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -1,4 +1,5 @@ import { + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, @@ -8,9 +9,11 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { google } from 'googleapis'; import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; import * as console from 'node:console'; -import axios from 'axios'; +import axios, { all } from 'axios'; import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import * as process from 'node:process'; +import dayjs from 'dayjs'; const clientAndYoutube = () => { const client = new google.auth.OAuth2({ @@ -25,13 +28,19 @@ const clientAndYoutube = () => { auth: newClient, }); + const youtubeAnalytics = (newClient: OAuth2Client) => + google.youtubeAnalytics({ + version: 'v2', + auth: newClient, + }); + const oauth2 = (newClient: OAuth2Client) => google.oauth2({ version: 'v2', auth: newClient, }); - return { client, youtube, oauth2 }; + return { client, youtube, oauth2, youtubeAnalytics }; }; export class YoutubeProvider extends SocialAbstract implements SocialProvider { @@ -79,6 +88,8 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { 'https://www.googleapis.com/auth/youtube.readonly', 'https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtubepartner', + 'https://www.googleapis.com/auth/youtubepartner', + 'https://www.googleapis.com/auth/yt-analytics.readonly', ], }), codeVerifier: makeId(10), @@ -162,4 +173,87 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { } return []; } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const endDate = dayjs().format('YYYY-MM-DD'); + const startDate = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + const { client, youtubeAnalytics } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtubeAnalytics(client); + const { data } = await youtubeClient.reports.query({ + ids: 'channel==MINE', + startDate, + endDate, + metrics: + 'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,likes,subscribersLost', + dimensions: 'day', + sort: 'day', + }); + + const columns = data?.columnHeaders?.map((p) => p.name)!; + const mappedData = data?.rows?.map((p) => { + return columns.reduce((acc, curr, index) => { + acc[curr!] = p[index]; + return acc; + }, {} as any); + }); + + const acc = [] as any[]; + acc.push({ + label: 'Estimated Minutes Watched', + data: mappedData?.map((p: any) => ({ + total: p.estimatedMinutesWatched, + date: p.day, + })), + }); + + acc.push({ + label: 'Average View Duration', + average: true, + data: mappedData?.map((p: any) => ({ + total: p.averageViewDuration, + date: p.day, + })), + }); + + acc.push({ + label: 'Average View Percentage', + average: true, + data: mappedData?.map((p: any) => ({ + total: p.averageViewPercentage, + date: p.day, + })), + }); + + acc.push({ + label: 'Subscribers Gained', + data: mappedData?.map((p: any) => ({ + total: p.subscribersGained, + date: p.day, + })), + }); + + acc.push({ + label: 'Subscribers Lost', + data: mappedData?.map((p: any) => ({ + total: p.subscribersLost, + date: p.day, + })), + }); + + acc.push({ + label: 'Likes', + data: mappedData?.map((p: any) => ({ + total: p.likes, + date: p.day, + })), + }); + + return acc; + } } diff --git a/libraries/react-shared-libraries/src/form/select.tsx b/libraries/react-shared-libraries/src/form/select.tsx index 546fe9cf..25d27d92 100644 --- a/libraries/react-shared-libraries/src/form/select.tsx +++ b/libraries/react-shared-libraries/src/form/select.tsx @@ -16,9 +16,18 @@ export const Select: FC< disableForm?: boolean; label: string; name: string; + hideErrors?: boolean; } > = (props) => { - const { label, className, disableForm, error, extraForm, ...rest } = props; + const { + label, + className, + hideErrors, + disableForm, + error, + extraForm, + ...rest + } = props; const form = useFormContext(); const err = useMemo(() => { if (error) return error; @@ -27,7 +36,7 @@ export const Select: FC< }, [form?.formState?.errors?.[props?.name!]?.message, error]); return ( -
+
{label}