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) => (
+
+ ))}
+
+
+ )}
+
+ {/* 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 [];
+ }
+ }
}