feat: post analytics
This commit is contained in:
parent
99889d59e2
commit
5cca81e002
|
|
@ -3,11 +3,15 @@ import { Organization } from '@prisma/client';
|
||||||
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
|
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
|
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
|
||||||
|
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
|
||||||
|
|
||||||
@ApiTags('Analytics')
|
@ApiTags('Analytics')
|
||||||
@Controller('/analytics')
|
@Controller('/analytics')
|
||||||
export class AnalyticsController {
|
export class AnalyticsController {
|
||||||
constructor(private _integrationService: IntegrationService) {}
|
constructor(
|
||||||
|
private _integrationService: IntegrationService,
|
||||||
|
private _postsService: PostsService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('/:integration')
|
@Get('/:integration')
|
||||||
async getIntegration(
|
async getIntegration(
|
||||||
|
|
@ -17,4 +21,13 @@ export class AnalyticsController {
|
||||||
) {
|
) {
|
||||||
return this._integrationService.checkAnalytics(org, integration, date);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -876,7 +876,7 @@ const CalendarItem: FC<{
|
||||||
>
|
>
|
||||||
<Preview />
|
<Preview />
|
||||||
</div>{' '}
|
</div>{' '}
|
||||||
{post.integration.providerIdentifier === 'x' && disableXAnalytics ? (
|
{((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? (
|
||||||
<></>
|
<></>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,138 @@
|
||||||
import React, { FC, Fragment, useCallback } from 'react';
|
import React, { FC, Fragment, useCallback, useMemo, useState } from 'react';
|
||||||
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||||
|
import { ChartSocial } from '@gitroom/frontend/components/analytics/chart-social';
|
||||||
|
import { Select } from '@gitroom/react/form/select';
|
||||||
|
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
|
||||||
|
|
||||||
|
interface AnalyticsData {
|
||||||
|
label: string;
|
||||||
|
data: Array<{ total: number; date: string }>;
|
||||||
|
percentageChange: number;
|
||||||
|
average?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const StatisticsModal: FC<{
|
export const StatisticsModal: FC<{
|
||||||
postId: string;
|
postId: string;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const { postId } = props;
|
const { postId } = props;
|
||||||
const modals = useModals();
|
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const fetch = useFetch();
|
const fetch = useFetch();
|
||||||
|
const [dateRange, setDateRange] = useState(7);
|
||||||
|
|
||||||
const loadStatistics = useCallback(async () => {
|
const loadStatistics = useCallback(async () => {
|
||||||
return (await fetch(`/posts/${postId}/statistics`)).json();
|
return (await fetch(`/posts/${postId}/statistics`)).json();
|
||||||
}, [postId]);
|
}, [postId, fetch]);
|
||||||
const closeAll = useCallback(() => {
|
|
||||||
modals.closeAll();
|
const loadPostAnalytics = useCallback(async () => {
|
||||||
}, []);
|
return (await fetch(`/analytics/post/${postId}?date=${dateRange}`)).json();
|
||||||
const { data, isLoading } = useSWR(
|
}, [postId, dateRange, fetch]);
|
||||||
|
|
||||||
|
const { data: statisticsData, isLoading: isLoadingStatistics } = useSWR(
|
||||||
`/posts/${postId}/statistics`,
|
`/posts/${postId}/statistics`,
|
||||||
loadStatistics
|
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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative min-h-[200px]">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>{t('loading', 'Loading')}</div>
|
<div className="flex items-center justify-center py-[40px]">
|
||||||
|
<LoadingComponent />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex flex-col gap-[24px]">
|
||||||
{data.clicks.length === 0 ? (
|
{/* Post Analytics Section */}
|
||||||
'No Results'
|
{analyticsData && Array.isArray(analyticsData) && analyticsData.length > 0 && (
|
||||||
) : (
|
<div className="flex flex-col gap-[14px]">
|
||||||
<>
|
<div className="flex items-center justify-between">
|
||||||
<div className="grid grid-cols-3 mt-[20px]">
|
<h3 className="text-[18px] font-[500]">
|
||||||
|
{t('post_analytics', 'Post Analytics')}
|
||||||
|
</h3>
|
||||||
|
<div className="max-w-[150px]">
|
||||||
|
<Select
|
||||||
|
label=""
|
||||||
|
name="date"
|
||||||
|
disableForm={true}
|
||||||
|
hideErrors={true}
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(e) => setDateRange(+e.target.value)}
|
||||||
|
>
|
||||||
|
{dateOptions.map((option) => (
|
||||||
|
<option key={option.key} value={option.key}>
|
||||||
|
{option.value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-[20px]">
|
||||||
|
{analyticsData.map((p: AnalyticsData, index: number) => (
|
||||||
|
<div key={`analytics-${index}`} className="flex">
|
||||||
|
<div className="flex-1 bg-newTableHeader rounded-[8px] py-[10px] px-[16px] gap-[10px] flex flex-col">
|
||||||
|
<div className="flex items-center gap-[14px]">
|
||||||
|
<div className="text-[20px]">{p.label}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-[156px] relative">
|
||||||
|
<ChartSocial data={p.data} key={`chart-${index}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[50px] leading-[60px]">{totals[index]}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Short Links Statistics Section */}
|
||||||
|
<div className="flex flex-col gap-[14px]">
|
||||||
|
<h3 className="text-[18px] font-[500]">
|
||||||
|
{t('short_links_statistics', 'Short Links Statistics')}
|
||||||
|
</h3>
|
||||||
|
{statisticsData?.clicks?.length === 0 ? (
|
||||||
|
<div className="text-gray-400">
|
||||||
|
{t('no_short_link_results', 'No short link results')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3">
|
||||||
<div className="bg-forth p-[4px] rounded-tl-lg">
|
<div className="bg-forth p-[4px] rounded-tl-lg">
|
||||||
{t('short_link', 'Short Link')}
|
{t('short_link', 'Short Link')}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -40,7 +142,7 @@ export const StatisticsModal: FC<{
|
||||||
<div className="bg-forth p-[4px] rounded-tr-lg">
|
<div className="bg-forth p-[4px] rounded-tr-lg">
|
||||||
{t('clicks', 'Clicks')}
|
{t('clicks', 'Clicks')}
|
||||||
</div>
|
</div>
|
||||||
{data.clicks.map((p: any) => (
|
{statisticsData?.clicks?.map((p: any) => (
|
||||||
<Fragment key={p.short}>
|
<Fragment key={p.short}>
|
||||||
<div className="p-[4px] py-[10px] bg-customColor6">
|
<div className="p-[4px] py-[10px] bg-customColor6">
|
||||||
{p.short}
|
{p.short}
|
||||||
|
|
@ -54,9 +156,17 @@ export const StatisticsModal: FC<{
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</>
|
|
||||||
|
{/* No analytics available message */}
|
||||||
|
{(!analyticsData || !Array.isArray(analyticsData) || analyticsData.length === 0) &&
|
||||||
|
(!statisticsData?.clicks || statisticsData.clicks.length === 0) && (
|
||||||
|
<div className="text-center text-gray-400 py-[20px]">
|
||||||
|
{t('no_statistics_available', 'No statistics available for this post')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,7 @@ export class PostsRepository {
|
||||||
content: true,
|
content: true,
|
||||||
publishDate: true,
|
publishDate: true,
|
||||||
releaseURL: true,
|
releaseURL: true,
|
||||||
submittedForOrganizationId: true,
|
releaseId: true,
|
||||||
submittedForOrderId: true,
|
|
||||||
state: true,
|
state: true,
|
||||||
intervalInDays: true,
|
intervalInDays: true,
|
||||||
group: true,
|
group: true,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ import {
|
||||||
organizationId,
|
organizationId,
|
||||||
postId as postIdSearchParam,
|
postId as postIdSearchParam,
|
||||||
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
|
} 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 & {
|
type PostWithConditionals = Post & {
|
||||||
integration?: Integration;
|
integration?: Integration;
|
||||||
|
|
@ -46,7 +51,8 @@ export class PostsService {
|
||||||
private _mediaService: MediaService,
|
private _mediaService: MediaService,
|
||||||
private _shortLinkService: ShortLinkService,
|
private _shortLinkService: ShortLinkService,
|
||||||
private _openaiService: OpenaiService,
|
private _openaiService: OpenaiService,
|
||||||
private _temporalService: TemporalService
|
private _temporalService: TemporalService,
|
||||||
|
private _refreshIntegrationService: RefreshIntegrationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
searchForMissingThreeHoursPosts() {
|
searchForMissingThreeHoursPosts() {
|
||||||
|
|
@ -57,6 +63,85 @@ export class PostsService {
|
||||||
return this._postRepository.updatePost(id, postId, releaseURL);
|
return this._postRepository.updatePost(id, postId, releaseURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkPostAnalytics(
|
||||||
|
orgId: string,
|
||||||
|
postId: string,
|
||||||
|
date: number,
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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) {
|
async getStatistics(orgId: string, id: string) {
|
||||||
const getPost = await this.getPostsRecursively(id, true, orgId, true);
|
const getPost = await this.getPostsRecursively(id, true, orgId, true);
|
||||||
const content = getPost.map((p) => p.content);
|
const content = getPost.map((p) => p.content);
|
||||||
|
|
|
||||||
|
|
@ -187,4 +187,14 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
|
||||||
): Promise<AnalyticsData[]> {
|
): Promise<AnalyticsData[]> {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
// Dribbble doesn't provide detailed post-level analytics via their API
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -462,4 +462,79 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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<string, number>).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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -560,4 +560,15 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
// 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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -741,4 +741,73 @@ export class InstagramProvider
|
||||||
)}&access_token=${accessToken}`
|
)}&access_token=${accessToken}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number,
|
||||||
|
type = 'graph.facebook.com'
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,4 +193,19 @@ export class InstagramStandaloneProvider
|
||||||
'graph.instagram.com'
|
'graph.instagram.com'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
) {
|
||||||
|
return instagramProvider.postAnalytics(
|
||||||
|
integrationId,
|
||||||
|
accessToken,
|
||||||
|
postId,
|
||||||
|
date,
|
||||||
|
'graph.instagram.com'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,132 @@ export class LinkedinPageProvider
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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({
|
@Plug({
|
||||||
identifier: 'linkedin-page-autoRepostPost',
|
identifier: 'linkedin-page-autoRepostPost',
|
||||||
title: 'Auto Repost Posts',
|
title: 'Auto Repost Posts',
|
||||||
|
|
@ -764,3 +890,30 @@ export interface TimeRange {
|
||||||
start: number;
|
start: number;
|
||||||
end: 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -358,4 +358,78 @@ export class PinterestProvider
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ export interface IAuthenticator {
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
date: number
|
date: number
|
||||||
): Promise<AnalyticsData[]>;
|
): Promise<AnalyticsData[]>;
|
||||||
|
postAnalytics?(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
fromDate: number,
|
||||||
|
): Promise<AnalyticsData[]>;
|
||||||
changeNickname?(
|
changeNickname?(
|
||||||
id: string,
|
id: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|
@ -46,6 +52,7 @@ export interface AnalyticsData {
|
||||||
percentageChange: number;
|
percentageChange: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type GenerateAuthUrlResponse = {
|
export type GenerateAuthUrlResponse = {
|
||||||
url: string;
|
url: string;
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,68 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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(
|
// override async mention(
|
||||||
// token: string,
|
// token: string,
|
||||||
// data: { query: string },
|
// data: { query: string },
|
||||||
|
|
|
||||||
|
|
@ -590,6 +590,96 @@ export class XProvider extends SocialAbstract implements SocialProvider {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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 }) {
|
override async mention(token: string, d: { query: string }) {
|
||||||
const [accessTokenSplit, accessSecretSplit] = token.split(':');
|
const [accessTokenSplit, accessSecretSplit] = token.split(':');
|
||||||
const client = new TwitterApi({
|
const client = new TwitterApi({
|
||||||
|
|
|
||||||
|
|
@ -449,4 +449,71 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postAnalytics(
|
||||||
|
integrationId: string,
|
||||||
|
accessToken: string,
|
||||||
|
postId: string,
|
||||||
|
date: number
|
||||||
|
): Promise<AnalyticsData[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue