From 5cca81e0029313fde34e7b248d6d2ac2d5565ff4 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 21 Jan 2026 15:11:29 +0700 Subject: [PATCH] feat: post analytics --- .../src/api/routes/analytics.controller.ts | 15 +- .../src/components/launches/calendar.tsx | 2 +- .../src/components/launches/statistics.tsx | 150 ++++++++++++++--- .../database/prisma/posts/posts.repository.ts | 3 +- .../database/prisma/posts/posts.service.ts | 87 +++++++++- .../integrations/social/dribbble.provider.ts | 10 ++ .../integrations/social/facebook.provider.ts | 75 +++++++++ .../src/integrations/social/gmb.provider.ts | 11 ++ .../integrations/social/instagram.provider.ts | 69 ++++++++ .../social/instagram.standalone.provider.ts | 15 ++ .../social/linkedin.page.provider.ts | 153 ++++++++++++++++++ .../integrations/social/pinterest.provider.ts | 74 +++++++++ .../social/social.integrations.interface.ts | 7 + .../integrations/social/threads.provider.ts | 62 +++++++ .../src/integrations/social/x.provider.ts | 90 +++++++++++ .../integrations/social/youtube.provider.ts | 67 ++++++++ 16 files changed, 865 insertions(+), 25 deletions(-) diff --git a/apps/backend/src/api/routes/analytics.controller.ts b/apps/backend/src/api/routes/analytics.controller.ts index 8c3201ef..a9bbcc2f 100644 --- a/apps/backend/src/api/routes/analytics.controller.ts +++ b/apps/backend/src/api/routes/analytics.controller.ts @@ -3,11 +3,15 @@ import { Organization } from '@prisma/client'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { ApiTags } from '@nestjs/swagger'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; @ApiTags('Analytics') @Controller('/analytics') export class AnalyticsController { - constructor(private _integrationService: IntegrationService) {} + constructor( + private _integrationService: IntegrationService, + private _postsService: PostsService + ) {} @Get('/:integration') async getIntegration( @@ -17,4 +21,13 @@ export class AnalyticsController { ) { return this._integrationService.checkAnalytics(org, integration, date); } + + @Get('/post/:postId') + async getPostAnalytics( + @GetOrgFromRequest() org: Organization, + @Param('postId') postId: string, + @Query('date') date: string + ) { + return this._postsService.checkPostAnalytics(org.id, postId, +date); + } } diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index f0dbb4f6..c2143821 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -876,7 +876,7 @@ const CalendarItem: FC<{ > {' '} - {post.integration.providerIdentifier === 'x' && disableXAnalytics ? ( + {((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? ( <> ) : (
; + percentageChange: number; + average?: boolean; +} + export const StatisticsModal: FC<{ postId: string; }> = (props) => { const { postId } = props; - const modals = useModals(); const t = useT(); const fetch = useFetch(); + const [dateRange, setDateRange] = useState(7); + const loadStatistics = useCallback(async () => { return (await fetch(`/posts/${postId}/statistics`)).json(); - }, [postId]); - const closeAll = useCallback(() => { - modals.closeAll(); - }, []); - const { data, isLoading } = useSWR( + }, [postId, fetch]); + + const loadPostAnalytics = useCallback(async () => { + return (await fetch(`/analytics/post/${postId}?date=${dateRange}`)).json(); + }, [postId, dateRange, fetch]); + + const { data: statisticsData, isLoading: isLoadingStatistics } = useSWR( `/posts/${postId}/statistics`, loadStatistics ); + + const { data: analyticsData, isLoading: isLoadingAnalytics } = useSWR( + `/analytics/post/${postId}?date=${dateRange}`, + loadPostAnalytics, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnMount: true, + refreshWhenHidden: false, + refreshWhenOffline: false, + } + ); + + const dateOptions = useMemo(() => { + return [ + { key: 7, value: t('7_days', '7 Days') }, + { key: 30, value: t('30_days', '30 Days') }, + { key: 90, value: t('90_days', '90 Days') }, + ]; + }, [t]); + + const totals = useMemo(() => { + if (!analyticsData || !Array.isArray(analyticsData)) return []; + return analyticsData.map((p: AnalyticsData) => { + const value = + (p?.data?.reduce((acc: number, curr: any) => acc + Number(curr.total), 0) || 0) / + (p.average ? p.data.length : 1); + if (p.average) { + return value.toFixed(2) + '%'; + } + return Math.round(value); + }); + }, [analyticsData]); + + const isLoading = isLoadingStatistics || isLoadingAnalytics; + return ( -
+
{isLoading ? ( -
{t('loading', 'Loading')}
+
+ +
) : ( - <> - {data.clicks.length === 0 ? ( - 'No Results' - ) : ( - <> -
+
+ {/* Post Analytics Section */} + {analyticsData && Array.isArray(analyticsData) && analyticsData.length > 0 && ( +
+
+

+ {t('post_analytics', 'Post Analytics')} +

+
+ +
+
+
+ {analyticsData.map((p: AnalyticsData, index: number) => ( +
+
+
+
{p.label}
+
+
+
+ +
+
+
{totals[index]}
+
+
+ ))} +
+
+ )} + + {/* Short Links Statistics Section */} +
+

+ {t('short_links_statistics', 'Short Links Statistics')} +

+ {statisticsData?.clicks?.length === 0 ? ( +
+ {t('no_short_link_results', 'No short link results')} +
+ ) : ( +
{t('short_link', 'Short Link')}
@@ -40,7 +142,7 @@ export const StatisticsModal: FC<{
{t('clicks', 'Clicks')}
- {data.clicks.map((p: any) => ( + {statisticsData?.clicks?.map((p: any) => (
{p.short} @@ -54,9 +156,17 @@ export const StatisticsModal: FC<{ ))}
- - )} - + )} +
+ + {/* No analytics available message */} + {(!analyticsData || !Array.isArray(analyticsData) || analyticsData.length === 0) && + (!statisticsData?.clicks || statisticsData.clicks.length === 0) && ( +
+ {t('no_statistics_available', 'No statistics available for this post')} +
+ )} +
)}
); diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 56efa445..5b735cdf 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -168,8 +168,7 @@ export class PostsRepository { content: true, publishDate: true, releaseURL: true, - submittedForOrganizationId: true, - submittedForOrderId: true, + releaseId: true, state: true, intervalInDays: true, group: true, diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 98cc144b..aa7abd79 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -30,6 +30,11 @@ import { organizationId, postId as postIdSearchParam, } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; +import { AnalyticsData } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; +import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service'; type PostWithConditionals = Post & { integration?: Integration; @@ -46,7 +51,8 @@ export class PostsService { private _mediaService: MediaService, private _shortLinkService: ShortLinkService, private _openaiService: OpenaiService, - private _temporalService: TemporalService + private _temporalService: TemporalService, + private _refreshIntegrationService: RefreshIntegrationService ) {} searchForMissingThreeHoursPosts() { @@ -57,6 +63,85 @@ export class PostsService { return this._postRepository.updatePost(id, postId, releaseURL); } + async checkPostAnalytics( + orgId: string, + postId: string, + date: number, + forceRefresh = false + ): Promise { + const post = await this._postRepository.getPostById(postId, orgId); + if (!post || !post.releaseId) { + return []; + } + + const integrationProvider = this._integrationManager.getSocialIntegration( + post.integration.providerIdentifier + ); + + if (!integrationProvider.postAnalytics) { + return []; + } + + const getIntegration = post.integration!; + + if ( + dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || + forceRefresh + ) { + const data = await this._refreshIntegrationService.refresh( + getIntegration + ); + if (!data) { + return []; + } + + const { accessToken } = data; + + if (accessToken) { + getIntegration.token = accessToken; + + if (integrationProvider.refreshWait) { + await timer(10000); + } + } else { + await this._integrationService.disconnectChannel(orgId, getIntegration); + return []; + } + } + + const getIntegrationData = await ioRedis.get( + `integration:${orgId}:${post.id}:${date}` + ); + if (getIntegrationData) { + return JSON.parse(getIntegrationData); + } + + try { + const loadAnalytics = await integrationProvider.postAnalytics( + getIntegration.internalId, + getIntegration.token, + post.releaseId, + date + ); + await ioRedis.set( + `integration:${orgId}:${post.id}:${date}`, + JSON.stringify(loadAnalytics), + 'EX', + !process.env.NODE_ENV || process.env.NODE_ENV === 'development' + ? 1 + : 3600 + ); + return loadAnalytics; + } catch (e) { + console.log(e); + if (e instanceof RefreshToken) { + return this.checkPostAnalytics(orgId, postId, date, true); + } + } + + return []; + } + async getStatistics(orgId: string, id: string) { const getPost = await this.getPostsRecursively(id, true, orgId, true); const content = getPost.map((p) => p.content); diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts index 4b2574ed..ba5968ea 100644 --- a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts @@ -187,4 +187,14 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider { ): Promise { return Promise.resolve([]); } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + // Dribbble doesn't provide detailed post-level analytics via their API + return []; + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index ffd89c2e..56ebfd35 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -462,4 +462,79 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { })) || [] ); } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Fetch post insights from Facebook Graph API + const { data } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${postId}/insights?metric=post_impressions,post_impressions_unique,post_engaged_users,post_clicks,post_reactions_by_type_total&access_token=${accessToken}` + ) + ).json(); + + if (!data || data.length === 0) { + return []; + } + + const result: AnalyticsData[] = []; + + for (const metric of data) { + const value = metric.values?.[0]?.value; + if (value === undefined) continue; + + let label = ''; + let total = ''; + + switch (metric.name) { + case 'post_impressions': + label = 'Impressions'; + total = String(value); + break; + case 'post_impressions_unique': + label = 'Reach'; + total = String(value); + break; + case 'post_engaged_users': + label = 'Engaged Users'; + total = String(value); + break; + case 'post_clicks': + label = 'Clicks'; + total = String(value); + break; + case 'post_reactions_by_type_total': + // This returns an object with reaction types + if (typeof value === 'object') { + const totalReactions = Object.values(value as Record).reduce( + (sum: number, v: number) => sum + v, + 0 + ); + label = 'Reactions'; + total = String(totalReactions); + } + break; + } + + if (label) { + result.push({ + label, + percentageChange: 0, + data: [{ total, date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Facebook post analytics:', err); + return []; + } + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts index 3fa7de95..6c472da2 100644 --- a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts @@ -560,4 +560,15 @@ export class GmbProvider extends SocialAbstract implements SocialProvider { return []; } } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + // Google My Business local posts don't have detailed individual post analytics + // The API focuses on location-level metrics rather than post-level metrics + return []; + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index e7b58e4d..9d509f2f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -741,4 +741,73 @@ export class InstagramProvider )}&access_token=${accessToken}` ); } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number, + type = 'graph.facebook.com' + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Fetch media insights from Instagram Graph API + const { data } = await ( + await this.fetch( + `https://${type}/v21.0/${postId}/insights?metric=impressions,reach,engagement,saved,likes,comments,shares&access_token=${accessToken}` + ) + ).json(); + + if (!data || data.length === 0) { + return []; + } + + const result: AnalyticsData[] = []; + + for (const metric of data) { + const value = metric.values?.[0]?.value; + if (value === undefined) continue; + + let label = ''; + + switch (metric.name) { + case 'impressions': + label = 'Impressions'; + break; + case 'reach': + label = 'Reach'; + break; + case 'engagement': + label = 'Engagement'; + break; + case 'saved': + label = 'Saves'; + break; + case 'likes': + label = 'Likes'; + break; + case 'comments': + label = 'Comments'; + break; + case 'shares': + label = 'Shares'; + break; + } + + if (label) { + result.push({ + label, + percentageChange: 0, + data: [{ total: String(value), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Instagram post analytics:', err); + return []; + } + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts index fecd0470..5681d09e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts @@ -193,4 +193,19 @@ export class InstagramStandaloneProvider 'graph.instagram.com' ); } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ) { + return instagramProvider.postAnalytics( + integrationId, + accessToken, + postId, + date, + 'graph.instagram.com' + ); + } } 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 6327a243..f60944d3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -417,6 +417,132 @@ export class LinkedinPageProvider })); } + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const endDate = dayjs().unix() * 1000; + const startDate = dayjs().subtract(date, 'days').unix() * 1000; + + // Fetch share statistics for the specific post + const shareStatsUrl = `https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent( + `urn:li:organization:${integrationId}` + )}&shares=List(${encodeURIComponent(postId)})&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`; + + const { elements: shareElements }: { elements: PostShareStatElement[] } = + await ( + await this.fetch(shareStatsUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'LinkedIn-Version': '202511', + 'X-Restli-Protocol-Version': '2.0.0', + }, + }) + ).json(); + + // Also fetch social actions (likes, comments, shares) for the specific post + let socialActions: SocialActionsResponse | null = null; + try { + const socialActionsUrl = `https://api.linkedin.com/v2/socialActions/${encodeURIComponent( + postId + )}`; + socialActions = await ( + await this.fetch(socialActionsUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'LinkedIn-Version': '202511', + 'X-Restli-Protocol-Version': '2.0.0', + }, + }) + ).json(); + } catch (e) { + // Social actions may not be available for all posts + } + + // Process share statistics into time series data + const analytics = (shareElements || []).reduce( + (all, current) => { + if (typeof current?.totalShareStatistics !== 'undefined') { + const dateStr = dayjs(current.timeRange.start).format('YYYY-MM-DD'); + + all['Impressions'].push({ + total: current.totalShareStatistics.impressionCount || 0, + date: dateStr, + }); + + all['Unique Impressions'].push({ + total: current.totalShareStatistics.uniqueImpressionsCount || 0, + date: dateStr, + }); + + all['Clicks'].push({ + total: current.totalShareStatistics.clickCount || 0, + date: dateStr, + }); + + all['Likes'].push({ + total: current.totalShareStatistics.likeCount || 0, + date: dateStr, + }); + + all['Comments'].push({ + total: current.totalShareStatistics.commentCount || 0, + date: dateStr, + }); + + all['Shares'].push({ + total: current.totalShareStatistics.shareCount || 0, + date: dateStr, + }); + + all['Engagement'].push({ + total: current.totalShareStatistics.engagement || 0, + date: dateStr, + }); + } + return all; + }, + { + Impressions: [] as { total: number; date: string }[], + 'Unique Impressions': [] as { total: number; date: string }[], + Clicks: [] as { total: number; date: string }[], + Likes: [] as { total: number; date: string }[], + Comments: [] as { total: number; date: string }[], + Shares: [] as { total: number; date: string }[], + Engagement: [] as { total: number; date: string }[], + } + ); + + // If no time series data but we have social actions, create a single data point + if ( + Object.values(analytics).every((arr) => arr.length === 0) && + socialActions + ) { + const today = dayjs().format('YYYY-MM-DD'); + analytics['Likes'].push({ + total: socialActions.likesSummary?.totalLikes || 0, + date: today, + }); + analytics['Comments'].push({ + total: socialActions.commentsSummary?.totalFirstLevelComments || 0, + date: today, + }); + } + + // Filter out empty analytics + const result = Object.entries(analytics) + .filter(([_, data]) => data.length > 0) + .map(([label, data]) => ({ + label, + data, + percentageChange: 0, + })); + + return result as any; + } + @Plug({ identifier: 'linkedin-page-autoRepostPost', title: 'Auto Repost Posts', @@ -764,3 +890,30 @@ export interface TimeRange { start: number; end: number; } + +// Post analytics interfaces +export interface PostShareStatElement { + organizationalEntity: string; + share: string; + totalShareStatistics: { + uniqueImpressionsCount: number; + shareCount: number; + engagement: number; + clickCount: number; + likeCount: number; + impressionCount: number; + commentCount: number; + }; + timeRange: TimeRange; +} + +export interface SocialActionsResponse { + likesSummary?: { + totalLikes: number; + likedByCurrentUser: boolean; + }; + commentsSummary?: { + totalFirstLevelComments: number; + commentsState: string; + }; +} diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts index f9779010..8f4a0684 100644 --- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts @@ -358,4 +358,78 @@ export class PinterestProvider ] ); } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + try { + // Fetch pin analytics from Pinterest API + const response = await this.fetch( + `https://api.pinterest.com/v5/pins/${postId}/analytics?start_date=${since}&end_date=${today}&metric_types=IMPRESSION,PIN_CLICK,OUTBOUND_CLICK,SAVE`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + const data = await response.json(); + + if (!data || !data.all) { + return []; + } + + const result: AnalyticsData[] = []; + const metrics = data.all; + + if (metrics.lifetime_metrics) { + const lifetimeMetrics = metrics.lifetime_metrics; + + if (lifetimeMetrics.IMPRESSION !== undefined) { + result.push({ + label: 'Impressions', + percentageChange: 0, + data: [{ total: String(lifetimeMetrics.IMPRESSION), date: today }], + }); + } + + if (lifetimeMetrics.PIN_CLICK !== undefined) { + result.push({ + label: 'Pin Clicks', + percentageChange: 0, + data: [{ total: String(lifetimeMetrics.PIN_CLICK), date: today }], + }); + } + + if (lifetimeMetrics.OUTBOUND_CLICK !== undefined) { + result.push({ + label: 'Outbound Clicks', + percentageChange: 0, + data: [{ total: String(lifetimeMetrics.OUTBOUND_CLICK), date: today }], + }); + } + + if (lifetimeMetrics.SAVE !== undefined) { + result.push({ + label: 'Saves', + percentageChange: 0, + data: [{ total: String(lifetimeMetrics.SAVE), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Pinterest post analytics:', err); + return []; + } + } } 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 3ecfba88..377621fb 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -28,6 +28,12 @@ export interface IAuthenticator { accessToken: string, date: number ): Promise; + postAnalytics?( + integrationId: string, + accessToken: string, + postId: string, + fromDate: number, + ): Promise; changeNickname?( id: string, accessToken: string, @@ -46,6 +52,7 @@ export interface AnalyticsData { percentageChange: number; } + export type GenerateAuthUrlResponse = { url: string; codeVerifier: string; diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts index 0087c34a..eca2307d 100644 --- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts @@ -523,6 +523,68 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider { return false; } + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Fetch thread insights from Threads API + const { data } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${postId}/insights?metric=views,likes,replies,reposts,quotes&access_token=${accessToken}` + ) + ).json(); + + if (!data || data.length === 0) { + return []; + } + + const result: AnalyticsData[] = []; + + for (const metric of data) { + const value = metric.values?.[0]?.value ?? metric.total_value?.value; + if (value === undefined) continue; + + let label = ''; + + switch (metric.name) { + case 'views': + label = 'Views'; + break; + case 'likes': + label = 'Likes'; + break; + case 'replies': + label = 'Replies'; + break; + case 'reposts': + label = 'Reposts'; + break; + case 'quotes': + label = 'Quotes'; + break; + } + + if (label) { + result.push({ + label, + percentageChange: 0, + data: [{ total: String(value), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Threads post analytics:', err); + return []; + } + } + // override async mention( // token: string, // data: { query: string }, diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 95e7559c..fcfaa45b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -590,6 +590,96 @@ export class XProvider extends SocialAbstract implements SocialProvider { return []; } + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + if (process.env.DISABLE_X_ANALYTICS) { + return []; + } + + const today = dayjs().format('YYYY-MM-DD'); + + 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, + }); + + try { + // Fetch the specific tweet with public metrics + const tweet = await client.v2.singleTweet(postId, { + 'tweet.fields': ['public_metrics', 'created_at'], + }); + + if (!tweet?.data?.public_metrics) { + return []; + } + + const metrics = tweet.data.public_metrics; + + const result: AnalyticsData[] = []; + + if (metrics.impression_count !== undefined) { + result.push({ + label: 'Impressions', + percentageChange: 0, + data: [{ total: String(metrics.impression_count), date: today }], + }); + } + + if (metrics.like_count !== undefined) { + result.push({ + label: 'Likes', + percentageChange: 0, + data: [{ total: String(metrics.like_count), date: today }], + }); + } + + if (metrics.retweet_count !== undefined) { + result.push({ + label: 'Retweets', + percentageChange: 0, + data: [{ total: String(metrics.retweet_count), date: today }], + }); + } + + if (metrics.reply_count !== undefined) { + result.push({ + label: 'Replies', + percentageChange: 0, + data: [{ total: String(metrics.reply_count), date: today }], + }); + } + + if (metrics.quote_count !== undefined) { + result.push({ + label: 'Quotes', + percentageChange: 0, + data: [{ total: String(metrics.quote_count), date: today }], + }); + } + + if (metrics.bookmark_count !== undefined) { + result.push({ + label: 'Bookmarks', + percentageChange: 0, + data: [{ total: String(metrics.bookmark_count), date: today }], + }); + } + + return result; + } catch (err) { + console.log('Error fetching X post analytics:', err); + } + + return []; + } + override async mention(token: string, d: { query: string }) { const [accessTokenSplit, accessSecretSplit] = token.split(':'); const client = new TwitterApi({ diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 66760cf1..0254c429 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -449,4 +449,71 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { return []; } } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtube(client); + + // Fetch video statistics + const response = await youtubeClient.videos.list({ + part: ['statistics', 'snippet'], + id: [postId], + }); + + const video = response.data.items?.[0]; + + if (!video || !video.statistics) { + return []; + } + + const stats = video.statistics; + const result: AnalyticsData[] = []; + + if (stats.viewCount !== undefined) { + result.push({ + label: 'Views', + percentageChange: 0, + data: [{ total: String(stats.viewCount), date: today }], + }); + } + + if (stats.likeCount !== undefined) { + result.push({ + label: 'Likes', + percentageChange: 0, + data: [{ total: String(stats.likeCount), date: today }], + }); + } + + if (stats.commentCount !== undefined) { + result.push({ + label: 'Comments', + percentageChange: 0, + data: [{ total: String(stats.commentCount), date: today }], + }); + } + + if (stats.favoriteCount !== undefined) { + result.push({ + label: 'Favorites', + percentageChange: 0, + data: [{ total: String(stats.favoriteCount), date: today }], + }); + } + + return result; + } catch (err) { + console.error('Error fetching YouTube post analytics:', err); + return []; + } + } }