Skip to content

Commit 5cca81e

Browse files
committed
feat: post analytics
1 parent 99889d5 commit 5cca81e

File tree

16 files changed

+865
-25
lines changed

16 files changed

+865
-25
lines changed

apps/backend/src/api/routes/analytics.controller.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import { Organization } from '@prisma/client';
33
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
44
import { ApiTags } from '@nestjs/swagger';
55
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
6+
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
67

78
@ApiTags('Analytics')
89
@Controller('/analytics')
910
export class AnalyticsController {
10-
constructor(private _integrationService: IntegrationService) {}
11+
constructor(
12+
private _integrationService: IntegrationService,
13+
private _postsService: PostsService
14+
) {}
1115

1216
@Get('/:integration')
1317
async getIntegration(
@@ -17,4 +21,13 @@ export class AnalyticsController {
1721
) {
1822
return this._integrationService.checkAnalytics(org, integration, date);
1923
}
24+
25+
@Get('/post/:postId')
26+
async getPostAnalytics(
27+
@GetOrgFromRequest() org: Organization,
28+
@Param('postId') postId: string,
29+
@Query('date') date: string
30+
) {
31+
return this._postsService.checkPostAnalytics(org.id, postId, +date);
32+
}
2033
}

apps/frontend/src/components/launches/calendar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ const CalendarItem: FC<{
876876
>
877877
<Preview />
878878
</div>{' '}
879-
{post.integration.providerIdentifier === 'x' && disableXAnalytics ? (
879+
{((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? (
880880
<></>
881881
) : (
882882
<div

apps/frontend/src/components/launches/statistics.tsx

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,138 @@
1-
import React, { FC, Fragment, useCallback } from 'react';
2-
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
1+
import React, { FC, Fragment, useCallback, useMemo, useState } from 'react';
32
import useSWR from 'swr';
43
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
54
import { useT } from '@gitroom/react/translation/get.transation.service.client';
5+
import { ChartSocial } from '@gitroom/frontend/components/analytics/chart-social';
6+
import { Select } from '@gitroom/react/form/select';
7+
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
8+
9+
interface AnalyticsData {
10+
label: string;
11+
data: Array<{ total: number; date: string }>;
12+
percentageChange: number;
13+
average?: boolean;
14+
}
15+
616
export const StatisticsModal: FC<{
717
postId: string;
818
}> = (props) => {
919
const { postId } = props;
10-
const modals = useModals();
1120
const t = useT();
1221
const fetch = useFetch();
22+
const [dateRange, setDateRange] = useState(7);
23+
1324
const loadStatistics = useCallback(async () => {
1425
return (await fetch(`/posts/${postId}/statistics`)).json();
15-
}, [postId]);
16-
const closeAll = useCallback(() => {
17-
modals.closeAll();
18-
}, []);
19-
const { data, isLoading } = useSWR(
26+
}, [postId, fetch]);
27+
28+
const loadPostAnalytics = useCallback(async () => {
29+
return (await fetch(`/analytics/post/${postId}?date=${dateRange}`)).json();
30+
}, [postId, dateRange, fetch]);
31+
32+
const { data: statisticsData, isLoading: isLoadingStatistics } = useSWR(
2033
`/posts/${postId}/statistics`,
2134
loadStatistics
2235
);
36+
37+
const { data: analyticsData, isLoading: isLoadingAnalytics } = useSWR(
38+
`/analytics/post/${postId}?date=${dateRange}`,
39+
loadPostAnalytics,
40+
{
41+
revalidateOnFocus: false,
42+
revalidateOnReconnect: false,
43+
revalidateIfStale: false,
44+
revalidateOnMount: true,
45+
refreshWhenHidden: false,
46+
refreshWhenOffline: false,
47+
}
48+
);
49+
50+
const dateOptions = useMemo(() => {
51+
return [
52+
{ key: 7, value: t('7_days', '7 Days') },
53+
{ key: 30, value: t('30_days', '30 Days') },
54+
{ key: 90, value: t('90_days', '90 Days') },
55+
];
56+
}, [t]);
57+
58+
const totals = useMemo(() => {
59+
if (!analyticsData || !Array.isArray(analyticsData)) return [];
60+
return analyticsData.map((p: AnalyticsData) => {
61+
const value =
62+
(p?.data?.reduce((acc: number, curr: any) => acc + Number(curr.total), 0) || 0) /
63+
(p.average ? p.data.length : 1);
64+
if (p.average) {
65+
return value.toFixed(2) + '%';
66+
}
67+
return Math.round(value);
68+
});
69+
}, [analyticsData]);
70+
71+
const isLoading = isLoadingStatistics || isLoadingAnalytics;
72+
2373
return (
24-
<div className="relative">
74+
<div className="relative min-h-[200px]">
2575
{isLoading ? (
26-
<div>{t('loading', 'Loading')}</div>
76+
<div className="flex items-center justify-center py-[40px]">
77+
<LoadingComponent />
78+
</div>
2779
) : (
28-
<>
29-
{data.clicks.length === 0 ? (
30-
'No Results'
31-
) : (
32-
<>
33-
<div className="grid grid-cols-3 mt-[20px]">
80+
<div className="flex flex-col gap-[24px]">
81+
{/* Post Analytics Section */}
82+
{analyticsData && Array.isArray(analyticsData) && analyticsData.length > 0 && (
83+
<div className="flex flex-col gap-[14px]">
84+
<div className="flex items-center justify-between">
85+
<h3 className="text-[18px] font-[500]">
86+
{t('post_analytics', 'Post Analytics')}
87+
</h3>
88+
<div className="max-w-[150px]">
89+
<Select
90+
label=""
91+
name="date"
92+
disableForm={true}
93+
hideErrors={true}
94+
value={dateRange}
95+
onChange={(e) => setDateRange(+e.target.value)}
96+
>
97+
{dateOptions.map((option) => (
98+
<option key={option.key} value={option.key}>
99+
{option.value}
100+
</option>
101+
))}
102+
</Select>
103+
</div>
104+
</div>
105+
<div className="grid grid-cols-3 gap-[20px]">
106+
{analyticsData.map((p: AnalyticsData, index: number) => (
107+
<div key={`analytics-${index}`} className="flex">
108+
<div className="flex-1 bg-newTableHeader rounded-[8px] py-[10px] px-[16px] gap-[10px] flex flex-col">
109+
<div className="flex items-center gap-[14px]">
110+
<div className="text-[20px]">{p.label}</div>
111+
</div>
112+
<div className="flex-1">
113+
<div className="h-[156px] relative">
114+
<ChartSocial data={p.data} key={`chart-${index}`} />
115+
</div>
116+
</div>
117+
<div className="text-[50px] leading-[60px]">{totals[index]}</div>
118+
</div>
119+
</div>
120+
))}
121+
</div>
122+
</div>
123+
)}
124+
125+
{/* Short Links Statistics Section */}
126+
<div className="flex flex-col gap-[14px]">
127+
<h3 className="text-[18px] font-[500]">
128+
{t('short_links_statistics', 'Short Links Statistics')}
129+
</h3>
130+
{statisticsData?.clicks?.length === 0 ? (
131+
<div className="text-gray-400">
132+
{t('no_short_link_results', 'No short link results')}
133+
</div>
134+
) : (
135+
<div className="grid grid-cols-3">
34136
<div className="bg-forth p-[4px] rounded-tl-lg">
35137
{t('short_link', 'Short Link')}
36138
</div>
@@ -40,7 +142,7 @@ export const StatisticsModal: FC<{
40142
<div className="bg-forth p-[4px] rounded-tr-lg">
41143
{t('clicks', 'Clicks')}
42144
</div>
43-
{data.clicks.map((p: any) => (
145+
{statisticsData?.clicks?.map((p: any) => (
44146
<Fragment key={p.short}>
45147
<div className="p-[4px] py-[10px] bg-customColor6">
46148
{p.short}
@@ -54,9 +156,17 @@ export const StatisticsModal: FC<{
54156
</Fragment>
55157
))}
56158
</div>
57-
</>
58-
)}
59-
</>
159+
)}
160+
</div>
161+
162+
{/* No analytics available message */}
163+
{(!analyticsData || !Array.isArray(analyticsData) || analyticsData.length === 0) &&
164+
(!statisticsData?.clicks || statisticsData.clicks.length === 0) && (
165+
<div className="text-center text-gray-400 py-[20px]">
166+
{t('no_statistics_available', 'No statistics available for this post')}
167+
</div>
168+
)}
169+
</div>
60170
)}
61171
</div>
62172
);

libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,7 @@ export class PostsRepository {
168168
content: true,
169169
publishDate: true,
170170
releaseURL: true,
171-
submittedForOrganizationId: true,
172-
submittedForOrderId: true,
171+
releaseId: true,
173172
state: true,
174173
intervalInDays: true,
175174
group: true,

libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import {
3030
organizationId,
3131
postId as postIdSearchParam,
3232
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
33+
import { AnalyticsData } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
34+
import { timer } from '@gitroom/helpers/utils/timer';
35+
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
36+
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
37+
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
3338

3439
type PostWithConditionals = Post & {
3540
integration?: Integration;
@@ -46,7 +51,8 @@ export class PostsService {
4651
private _mediaService: MediaService,
4752
private _shortLinkService: ShortLinkService,
4853
private _openaiService: OpenaiService,
49-
private _temporalService: TemporalService
54+
private _temporalService: TemporalService,
55+
private _refreshIntegrationService: RefreshIntegrationService
5056
) {}
5157

5258
searchForMissingThreeHoursPosts() {
@@ -57,6 +63,85 @@ export class PostsService {
5763
return this._postRepository.updatePost(id, postId, releaseURL);
5864
}
5965

66+
async checkPostAnalytics(
67+
orgId: string,
68+
postId: string,
69+
date: number,
70+
forceRefresh = false
71+
): Promise<AnalyticsData[]> {
72+
const post = await this._postRepository.getPostById(postId, orgId);
73+
if (!post || !post.releaseId) {
74+
return [];
75+
}
76+
77+
const integrationProvider = this._integrationManager.getSocialIntegration(
78+
post.integration.providerIdentifier
79+
);
80+
81+
if (!integrationProvider.postAnalytics) {
82+
return [];
83+
}
84+
85+
const getIntegration = post.integration!;
86+
87+
if (
88+
dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) ||
89+
forceRefresh
90+
) {
91+
const data = await this._refreshIntegrationService.refresh(
92+
getIntegration
93+
);
94+
if (!data) {
95+
return [];
96+
}
97+
98+
const { accessToken } = data;
99+
100+
if (accessToken) {
101+
getIntegration.token = accessToken;
102+
103+
if (integrationProvider.refreshWait) {
104+
await timer(10000);
105+
}
106+
} else {
107+
await this._integrationService.disconnectChannel(orgId, getIntegration);
108+
return [];
109+
}
110+
}
111+
112+
const getIntegrationData = await ioRedis.get(
113+
`integration:${orgId}:${post.id}:${date}`
114+
);
115+
if (getIntegrationData) {
116+
return JSON.parse(getIntegrationData);
117+
}
118+
119+
try {
120+
const loadAnalytics = await integrationProvider.postAnalytics(
121+
getIntegration.internalId,
122+
getIntegration.token,
123+
post.releaseId,
124+
date
125+
);
126+
await ioRedis.set(
127+
`integration:${orgId}:${post.id}:${date}`,
128+
JSON.stringify(loadAnalytics),
129+
'EX',
130+
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
131+
? 1
132+
: 3600
133+
);
134+
return loadAnalytics;
135+
} catch (e) {
136+
console.log(e);
137+
if (e instanceof RefreshToken) {
138+
return this.checkPostAnalytics(orgId, postId, date, true);
139+
}
140+
}
141+
142+
return [];
143+
}
144+
60145
async getStatistics(orgId: string, id: string) {
61146
const getPost = await this.getPostsRecursively(id, true, orgId, true);
62147
const content = getPost.map((p) => p.content);

libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,14 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
187187
): Promise<AnalyticsData[]> {
188188
return Promise.resolve([]);
189189
}
190+
191+
async postAnalytics(
192+
integrationId: string,
193+
accessToken: string,
194+
postId: string,
195+
date: number
196+
): Promise<AnalyticsData[]> {
197+
// Dribbble doesn't provide detailed post-level analytics via their API
198+
return [];
199+
}
190200
}

0 commit comments

Comments
 (0)