feat: analytics

This commit is contained in:
Nevo David 2024-06-03 19:57:03 +07:00
parent 4a5e93c356
commit 01161692ba
24 changed files with 1205 additions and 204 deletions

View File

@ -1,42 +1,83 @@
import {Body, Controller, Get, Post} from '@nestjs/common';
import {Organization} from "@prisma/client";
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {StarsService} from "@gitroom/nestjs-libraries/database/prisma/stars/stars.service";
import dayjs from "dayjs";
import {StarsListDto} from "@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto";
import {ApiTags} from "@nestjs/swagger";
import { Body, Controller, Get, Inject, Param, Post, Query } from '@nestjs/common';
import { Organization } from '@prisma/client';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import dayjs from 'dayjs';
import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto';
import { ApiTags } from '@nestjs/swagger';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
@ApiTags('Analytics')
@Controller('/analytics')
export class AnalyticsController {
constructor(
private _starsService: StarsService
) {
}
@Get('/')
async getStars(
@GetOrgFromRequest() org: Organization
) {
return this._starsService.getStars(org.id);
constructor(
private _starsService: StarsService,
private _integrationService: IntegrationService,
private _integrationManager: IntegrationManager
) {}
@Get('/')
async getStars(@GetOrgFromRequest() org: Organization) {
return this._starsService.getStars(org.id);
}
@Get('/trending')
async getTrending() {
const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00'));
const last = todayTrending.isAfter(dayjs())
? todayTrending.subtract(1, 'day')
: todayTrending;
const nextTrending = last.add(1, 'day');
return {
last: last.format('YYYY-MM-DD HH:mm:ss'),
predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'),
};
}
@Post('/stars')
async getStarsFilter(
@GetOrgFromRequest() org: Organization,
@Body() starsFilter: StarsListDto
) {
return {
stars: await this._starsService.getStarsFilter(org.id, starsFilter),
};
}
@Get('/:integration')
async getIntegration(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Query('date') date: string
) {
const getIntegration = await this._integrationService.getIntegrationById(
org.id,
integration
);
if (!getIntegration) {
throw new Error('Invalid integration');
}
@Get('/trending')
async getTrending() {
const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00'));
const last = todayTrending.isAfter(dayjs()) ? todayTrending.subtract(1, 'day') : todayTrending;
const nextTrending = last.add(1, 'day');
if (getIntegration.type === 'social') {
const integrationProvider = this._integrationManager.getSocialIntegration(
getIntegration.providerIdentifier
);
return {
last: last.format('YYYY-MM-DD HH:mm:ss'),
predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'),
}
}
const getIntegrationData = await ioRedis.get(`integration:${org.id}:${integration}:${date}`);
if (getIntegrationData) {
return JSON.parse(getIntegrationData)
}
@Post('/stars')
async getStarsFilter(
@GetOrgFromRequest() org: Organization,
@Body() starsFilter: StarsListDto
) {
return {stars: await this._starsService.getStarsFilter(org.id, starsFilter)};
if (integrationProvider.analytics) {
const loadAnalytics = await integrationProvider.analytics(getIntegration.internalId, getIntegration.token, +date);
await ioRedis.set(`integration:${org.id}:${integration}:${date}`, JSON.stringify(loadAnalytics), 'EX', !process.env.NODE_ENV || process.env.NODE_ENV === 'development' ? 1 : 3600);
return loadAnalytics;
}
return {};
}
}
}

View File

@ -199,6 +199,8 @@ export class IntegrationsController {
throw new Error('Invalid state');
}
await ioRedis.del(`login:${body.state}`);
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const {

View File

@ -2,16 +2,19 @@ import { isGeneral } from '@gitroom/react/helpers/is.general';
export const dynamic = 'force-dynamic';
import {AnalyticsComponent} from "@gitroom/frontend/components/analytics/analytics.component";
import {Metadata} from "next";
import { AnalyticsComponent } from '@gitroom/frontend/components/analytics/analytics.component';
import { Metadata } from 'next';
import { PlatformAnalytics } from '@gitroom/frontend/components/platform-analytics/platform.analytics';
export const metadata: Metadata = {
title: `${isGeneral() ? 'Postiz' : 'Gitroom'} Analytics`,
description: '',
}
};
export default async function Index() {
return (
<AnalyticsComponent />
<>
{isGeneral() ? <PlatformAnalytics /> : <AnalyticsComponent />}
</>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import { FC, useEffect, useMemo, useRef } from 'react';
import DrawChart from 'chart.js/auto';
import { TotalList } from '@gitroom/frontend/components/analytics/stars.and.forks.interface';
import dayjs from 'dayjs';
import { chunk } from 'lodash';
function mergeDataPoints(data: TotalList[], numPoints: number): TotalList[] {
const res = chunk(data, Math.ceil(data.length / numPoints));
return res.map((row) => {
return {
date: `${row[0].date} - ${row?.at(-1)?.date}`,
total: row.reduce((acc, curr) => acc + curr.total, 0),
};
});
}
export const ChartSocial: FC<{ data: TotalList[] }> = (props) => {
const { data } = props;
const list = useMemo(() => {
return mergeDataPoints(data, 7);
}, [data]);
const ref = useRef<any>(null);
const chart = useRef<null | DrawChart>(null);
useEffect(() => {
const gradient = ref.current
.getContext('2d')
.createLinearGradient(0, 0, 0, ref.current.height);
gradient.addColorStop(0, 'rgb(20,101,6)'); // Start color with some transparency
gradient.addColorStop(1, 'rgb(9, 11, 19, 1)');
chart.current = new DrawChart(ref.current!, {
type: 'line',
options: {
maintainAspectRatio: false,
responsive: true,
layout: {
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
scales: {
y: {
beginAtZero: true,
display: false,
},
x: {
display: false,
ticks: {
stepSize: 10,
maxTicksLimit: 7,
},
},
},
plugins: {
legend: {
display: false,
},
},
},
data: {
labels: list.map((row) => row.date),
datasets: [
{
borderColor: '#fff',
// @ts-ignore
label: 'Total',
backgroundColor: gradient,
fill: true,
// @ts-ignore
data: list.map((row) => row.total),
},
],
},
});
return () => {
chart?.current?.destroy();
};
}, []);
return <canvas className="w-full h-full" ref={ref} />;
};

View File

@ -3,6 +3,11 @@ export interface StarsList {
date: string;
}
export interface TotalList {
total: number;
date: string;
}
export interface ForksList {
totalForks: number;
date: string;

View File

@ -1,8 +1,28 @@
'use client';
export const Support = () => {
if (!process.env.NEXT_PUBLIC_DISCORD_SUPPORT) return null
if (!process.env.NEXT_PUBLIC_DISCORD_SUPPORT) return null;
return (
<div className="bg-[#612AD5] fixed right-[20px] bottom-[20px] z-[500] p-[20px] text-white rounded-[20px] cursor-pointer" onClick={() => window.open(process.env.NEXT_PUBLIC_DISCORD_SUPPORT)}>Discord Support</div>
)
}
<div
className="bg-[#fff] w-[194px] h-[58px] fixed right-[20px] bottom-[20px] z-[500] text-[16px] text-[#0E0E0E] rounded-[30px] !rounded-br-[0] cursor-pointer flex justify-center items-center gap-[10px]"
onClick={() => window.open(process.env.NEXT_PUBLIC_DISCORD_SUPPORT)}
>
<div>
<svg
width="32"
height="33"
viewBox="0 0 32 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mb-[4px]"
>
<path
d="M26.1303 11.347C24.3138 9.93899 22.134 9.23502 19.8331 9.11768L19.4697 9.4697C21.5284 9.93899 23.345 10.8776 25.0404 12.1683C22.9817 11.1123 20.6807 10.4084 18.2587 10.1737C17.5321 10.0563 16.9266 10.0563 16.2 10.0563C15.4734 10.0563 14.8679 10.0563 14.1413 10.1737C11.7193 10.4084 9.41833 11.1123 7.35963 12.1683C9.05501 10.8776 10.8716 9.93899 12.9303 9.4697L12.5669 9.11768C10.266 9.23502 8.08621 9.93899 6.26972 11.347C4.21101 15.1017 3.1211 19.3257 3 23.6669C4.81649 25.5443 7.35963 26.7177 10.0239 26.7177C10.0239 26.7177 10.8716 25.779 11.477 24.9576C9.90277 24.6057 8.44954 23.7843 7.48074 22.4937C8.32843 22.963 9.17611 23.4323 10.0239 23.7843C11.1138 24.2537 12.2037 24.4883 13.2936 24.723C14.2624 24.8403 15.2312 24.9576 16.2 24.9576C17.1688 24.9576 18.1376 24.8403 19.1064 24.723C20.1963 24.4883 21.2862 24.2537 22.3761 23.7843C23.2239 23.4323 24.0716 22.963 24.9193 22.4937C23.9505 23.7843 22.4972 24.6057 20.923 24.9576C21.5284 25.779 22.3761 26.7177 22.3761 26.7177C25.0404 26.7177 27.5835 25.5443 29.4 23.6669C29.2789 19.3257 28.189 15.1017 26.1303 11.347ZM12.2037 21.555C10.9927 21.555 9.90278 20.499 9.90278 19.2084C9.90278 17.9177 10.9927 16.8617 12.2037 16.8617C13.4147 16.8617 14.5046 17.9177 14.5046 19.2084C14.5046 20.499 13.4147 21.555 12.2037 21.555ZM20.1963 21.555C18.9853 21.555 17.8954 20.499 17.8954 19.2084C17.8954 17.9177 18.9853 16.8617 20.1963 16.8617C21.4073 16.8617 22.4972 17.9177 22.4972 19.2084C22.4972 20.499 21.4073 21.555 20.1963 21.555Z"
fill="#0E0E0E"
/>
</svg>
</div>
<div>Discord Support</div>
</div>
);
};

View File

@ -24,6 +24,15 @@ export const menuItems = [
icon: 'launches',
path: '/launches',
},
...(general
? [
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
]
: []),
...(!general
? [
{

View File

@ -0,0 +1,181 @@
'use client';
import useSWR from 'swr';
import { useCallback, useMemo, useState } from 'react';
import { orderBy } from 'lodash';
import clsx from 'clsx';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { RenderAnalytics } from '@gitroom/frontend/components/platform-analytics/render.analytics';
import { Select } from '@gitroom/react/form/select';
const allowedIntegrations = [
'facebook',
'instagram',
'linkedin-page',
'tiktok',
'youtube',
'pinterest'
];
export const PlatformAnalytics = () => {
const fetch = useFetch();
const [current, setCurrent] = useState(0);
const [key, setKey] = useState(7);
const load = useCallback(async () => {
const int = (await (await fetch('/integrations/list')).json()).integrations;
return int.filter((f: any) => allowedIntegrations.includes(f.identifier));
}, []);
const { data } = useSWR('analytics-list', load, {
fallbackData: [],
});
const sortedIntegrations = useMemo(() => {
return orderBy(
data,
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [data]);
const currentIntegration = useMemo(() => {
return sortedIntegrations[current];
}, [current, sortedIntegrations]);
const options = useMemo(() => {
if (!currentIntegration) {
return [];
}
const arr = [];
if (
['facebook', 'instagram', 'linkedin-page', 'pinterest', 'youtube'].indexOf(
currentIntegration.identifier
) !== -1
) {
arr.push({
key: 7,
value: '7 Days',
});
}
if (
['facebook', 'instagram', 'linkedin-page', 'pinterest', 'youtube'].indexOf(
currentIntegration.identifier
) !== -1
) {
arr.push({
key: 30,
value: '30 Days',
});
}
if (
['facebook', 'linkedin-page', 'pinterest', 'youtube'].indexOf(currentIntegration.identifier) !==
-1
) {
arr.push({
key: 90,
value: '90 Days',
});
}
return arr;
}, [currentIntegration]);
const keys = useMemo(() => {
if (!currentIntegration) {
return 7;
}
if (options.find((p) => p.key === key)) {
return key;
}
return options[0]?.key;
}, [key, currentIntegration]);
return (
<div className="flex gap-[30px] flex-1">
<div className="p-[16px] bg-[#080B14] overflow-hidden flex w-[220px]">
<div className="flex gap-[16px] flex-col overflow-hidden">
<div className="text-[20px] mb-[8px]">Channels</div>
{sortedIntegrations.map((integration, index) => (
<div
key={integration.id}
onClick={() => setCurrent(index)}
className={clsx(
'flex gap-[8px] items-center',
currentIntegration.id !== integration.id &&
'opacity-20 hover:opacity-100 cursor-pointer'
)}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer">
<div className="bg-red-500 w-[15px] h-[15px] rounded-full -left-[5px] -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-black/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
<div
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
</div>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<div className="max-w-[200px]">
<Select
className="bg-[#0A0B14] !border-0"
label=""
name="date"
disableForm={true}
hideErrors={true}
onChange={(e) => setKey(+e.target.value)}
>
{options.map((option) => (
<option key={option.key} value={option.key}>
{option.value}
</option>
))}
</Select>
</div>
<div className="flex-1">
{!!keys && !!currentIntegration && (
<RenderAnalytics integration={currentIntegration} date={keys} />
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,77 @@
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { Integration } from '@prisma/client';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { ChartSocial } from '@gitroom/frontend/components/analytics/chart-social';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
export const RenderAnalytics: FC<{ integration: Integration; date: number }> = (
props
) => {
const { integration, date } = props;
const [loading, setLoading] = useState(true);
const fetch = useFetch();
const load = useCallback(async () => {
setLoading(true);
const load = (
await fetch(`/analytics/${integration.id}?date=${date}`)
).json();
setLoading(false);
return load;
}, [integration, date]);
const { data } = useSWR(`/analytics-${integration?.id}-${date}`, load, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshWhenOffline: false,
revalidateOnMount: true,
});
const total = useMemo(() => {
return data?.map(
(p: any) => {
const value = (p?.data.reduce((acc: number, curr: any) => acc + curr.total, 0) || 0) /
(p.average ? p.data.length : 1);
if (p.average) {
return value.toFixed(2) + '%';
}
return value;
}
);
}, [data]);
if (loading) {
return (
<>
<LoadingComponent />
</>
);
}
return (
<div className="grid grid-cols-3 gap-[20px]">
{data?.map((p: any, index: number) => (
<div key={`pl-${index}`} className="flex">
<div className="flex-1 bg-secondary 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 {...p} key={`p-${index}`} />
</div>
</div>
<div className="text-[50px] leading-[60px]">{total[index]}</div>
</div>
</div>
))}
</div>
);
};

View File

@ -87,12 +87,6 @@ export async function middleware(request: NextRequest) {
return redirect;
}
if (isGeneral() && (nextUrl.pathname.indexOf('/analytics') > -1 || nextUrl.pathname.indexOf('/settings') > -1)) {
return NextResponse.redirect(
new URL('/launches', nextUrl.href)
);
}
if (nextUrl.pathname === '/') {
return NextResponse.redirect(
new URL(isGeneral() ? '/launches' : `/analytics`, nextUrl.href)

View File

@ -16,7 +16,7 @@ import { DribbbleProvider } from '@gitroom/nestjs-libraries/integrations/social/
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
const socialIntegrationList = [
new XProvider(),
...(process.env.IS_GENERAL !== 'true' ? [new XProvider()] : []),
new LinkedinProvider(),
new LinkedinPageProvider(),
new RedditProvider(),

View File

@ -1,4 +1,5 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
@ -14,7 +15,7 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
export class DribbbleProvider extends SocialAbstract implements SocialProvider {
identifier = 'dribbble';
name = 'Dribbbble';
name = 'Dribbble';
isBetweenSteps = false;
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
@ -67,10 +68,12 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
})
).json();
return teams?.map((team: any) => ({
id: team.id,
name: team.name,
})) || [];
return (
teams?.map((team: any) => ({
id: team.id,
name: team.name,
})) || []
);
}
async generateAuthUrl(refresh?: string) {
@ -268,4 +271,12 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
return [];
}
}
analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
return Promise.resolve([]);
}
}

View File

@ -1,4 +1,5 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
@ -32,7 +33,9 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
'https://www.facebook.com/v20.0/dialog/oauth' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/facebook${refresh ? `?refresh=${refresh}` : ''}`
`${process.env.FRONTEND_URL}/integrations/social/facebook${
refresh ? `?refresh=${refresh}` : ''
}`
)}` +
`&state=${state}` +
'&scope=pages_show_list,business_management,pages_manage_posts,pages_manage_engagement,pages_read_engagement',
@ -259,4 +262,37 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
...postsArray,
];
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const until = dayjs().format('YYYY-MM-DD');
const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const { data } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/insights?metric=page_impressions_unique,page_posts_impressions_unique,page_post_engagements,page_daily_follows,page_video_views&access_token=${accessToken}&period=day&since=${since}&until=${until}`
)
).json();
return data.map((d: any) => ({
label:
d.name === 'page_impressions_unique'
? 'Page Impressions'
: d.name === 'page_post_engagements'
? 'Posts Engagement'
: d.name === 'page_daily_follows'
? 'Page followers'
: d.name === 'page_video_views'
? 'Videos views'
: 'Posts Impressions',
percentageChange: 5,
data: d.values.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
}));
}
}

View File

@ -1,4 +1,5 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
@ -8,8 +9,12 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { chunk } from 'lodash';
export class InstagramProvider extends SocialAbstract implements SocialProvider {
export class InstagramProvider
extends SocialAbstract
implements SocialProvider
{
identifier = 'instagram';
name = 'Instagram';
isBetweenSteps = true;
@ -39,7 +44,7 @@ export class InstagramProvider extends SocialAbstract implements SocialProvider
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(
'instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments'
'instagram_basic,pages_show_list,pages_read_engagement,business_management,instagram_content_publish,instagram_manage_comments,instagram_manage_insights'
)}`,
codeVerifier: makeId(10),
state,
@ -88,7 +93,9 @@ export class InstagramProvider extends SocialAbstract implements SocialProvider
).json();
if (params.refresh) {
const findPage = (await this.pages(access_token)).find(p => p.id === params.refresh);
const findPage = (await this.pages(access_token)).find(
(p) => p.id === params.refresh
);
const information = await this.fetchPageInformation(access_token, {
id: params.refresh,
pageId: findPage?.pageId!,
@ -319,4 +326,30 @@ export class InstagramProvider extends SocialAbstract implements SocialProvider
return arr;
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const until = dayjs().format('YYYY-MM-DD');
const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const { data, ...all } = await (
await fetch(
`https://graph.facebook.com/v20.0/${id}/insights?metric=follower_count,impressions,reach,profile_views&access_token=${accessToken}&period=day&since=${since}&until=${until}`
)
).json();
console.log(all);
return data?.map((d: any) => ({
label: d.title,
percentageChange: 5,
data: d.values.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
})) || [];
}
}

View File

@ -1,4 +1,5 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
@ -6,6 +7,9 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import { number, string } from 'yup';
import dayjs from 'dayjs';
import { writeFileSync } from 'fs';
export class LinkedinPageProvider
extends LinkedinProvider
@ -121,7 +125,8 @@ export class LinkedinPageProvider
id: data.id,
name: data.localizedName,
access_token: accessToken,
picture: data?.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0].identifier,
picture:
data?.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0].identifier,
username: data.vanityName,
};
}
@ -195,4 +200,332 @@ export class LinkedinPageProvider
): Promise<PostResponse[]> {
return super.post(id, accessToken, postDetails, 'company');
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const endDate = dayjs().unix() * 1000;
const startDate = dayjs().subtract(date, 'days').unix() * 1000;
const { elements }: { elements: Root[]; paging: any } = await (
await fetch(
`https://api.linkedin.com/rest/organizationPageStatistics?q=organization&organization=${encodeURIComponent(
`urn:li:organization:${id}`
)}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Linkedin-Version': '202405',
'X-Restli-Protocol-Version': '2.0.0',
},
}
)
).json();
const { elements: elements2 }: { elements: Root[]; paging: any } = await (
await fetch(
`https://api.linkedin.com/rest/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(
`urn:li:organization:${id}`
)}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Linkedin-Version': '202405',
'X-Restli-Protocol-Version': '2.0.0',
},
}
)
).json();
const { elements: elements3 }: { elements: Root[]; paging: any } = await (
await fetch(
`https://api.linkedin.com/rest/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(
`urn:li:organization:${id}`
)}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Linkedin-Version': '202405',
'X-Restli-Protocol-Version': '2.0.0',
},
}
)
).json();
const analytics = [...elements2, ...elements, ...elements3].reduce(
(all, current) => {
if (
typeof current?.totalPageStatistics?.views?.allPageViews
?.pageViews !== 'undefined'
) {
all['Page Views'].push({
total: current.totalPageStatistics.views.allPageViews.pageViews,
date: dayjs(current.timeRange.start).format('YYYY-MM-DD'),
});
}
if (
typeof current?.followerGains?.organicFollowerGain !== 'undefined'
) {
all['Organic Followers'].push({
total: current?.followerGains?.organicFollowerGain,
date: dayjs(current.timeRange.start).format('YYYY-MM-DD'),
});
}
if (typeof current?.followerGains?.paidFollowerGain !== 'undefined') {
all['Paid Followers'].push({
total: current?.followerGains?.paidFollowerGain,
date: dayjs(current.timeRange.start).format('YYYY-MM-DD'),
});
}
if (typeof current?.totalShareStatistics !== 'undefined') {
all['Clicks'].push({
total: current?.totalShareStatistics.clickCount,
date: dayjs(current.timeRange.start).format('YYYY-MM-DD'),
});
all['Shares'].push({
total: current?.totalShareStatistics.shareCount,
date: dayjs(current.timeRange.start).format('YYYY-MM-DD'),
});
all['Engagement'].push({
total: current?.totalShareStatistics.engagement,
date: dayjs(current.timeRange.start).format('YYYY-MM-DD'),
});
all['Comments'].push({
total: current?.totalShareStatistics.commentCount,
date: dayjs(current.timeRange.start).format('YYYY-MM-DD'),
});
}
return all;
},
{
'Page Views': [] as any[],
Clicks: [] as any[],
Shares: [] as any[],
Engagement: [] as any[],
Comments: [] as any[],
'Organic Followers': [] as any[],
'Paid Followers': [] as any[],
}
);
return Object.keys(analytics).map((key) => ({
label: key,
data: analytics[
key as 'Page Views' | 'Organic Followers' | 'Paid Followers'
],
percentageChange: 5,
}));
}
}
export interface Root {
pageStatisticsByIndustryV2: any[];
pageStatisticsBySeniority: any[];
organization: string;
pageStatisticsByGeoCountry: any[];
pageStatisticsByTargetedContent: any[];
totalPageStatistics: TotalPageStatistics;
pageStatisticsByStaffCountRange: any[];
pageStatisticsByFunction: any[];
pageStatisticsByGeo: any[];
followerGains: { organicFollowerGain: number; paidFollowerGain: number };
timeRange: TimeRange;
totalShareStatistics: {
uniqueImpressionsCount: number;
shareCount: number;
engagement: number;
clickCount: number;
likeCount: number;
impressionCount: number;
commentCount: number;
};
}
export interface TotalPageStatistics {
clicks: Clicks;
views: Views;
}
export interface Clicks {
mobileCustomButtonClickCounts: any[];
desktopCustomButtonClickCounts: any[];
}
export interface Views {
mobileProductsPageViews: MobileProductsPageViews;
allDesktopPageViews: AllDesktopPageViews;
insightsPageViews: InsightsPageViews;
mobileAboutPageViews: MobileAboutPageViews;
allMobilePageViews: AllMobilePageViews;
productsPageViews: ProductsPageViews;
desktopProductsPageViews: DesktopProductsPageViews;
jobsPageViews: JobsPageViews;
peoplePageViews: PeoplePageViews;
overviewPageViews: OverviewPageViews;
mobileOverviewPageViews: MobileOverviewPageViews;
lifeAtPageViews: LifeAtPageViews;
desktopOverviewPageViews: DesktopOverviewPageViews;
mobileCareersPageViews: MobileCareersPageViews;
allPageViews: AllPageViews;
careersPageViews: CareersPageViews;
mobileJobsPageViews: MobileJobsPageViews;
mobileLifeAtPageViews: MobileLifeAtPageViews;
desktopJobsPageViews: DesktopJobsPageViews;
desktopPeoplePageViews: DesktopPeoplePageViews;
aboutPageViews: AboutPageViews;
desktopAboutPageViews: DesktopAboutPageViews;
mobilePeoplePageViews: MobilePeoplePageViews;
desktopCareersPageViews: DesktopCareersPageViews;
desktopInsightsPageViews: DesktopInsightsPageViews;
desktopLifeAtPageViews: DesktopLifeAtPageViews;
mobileInsightsPageViews: MobileInsightsPageViews;
}
export interface MobileProductsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface AllDesktopPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface InsightsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface MobileAboutPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface AllMobilePageViews {
pageViews: number;
uniquePageViews: number;
}
export interface ProductsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopProductsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface JobsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface PeoplePageViews {
pageViews: number;
uniquePageViews: number;
}
export interface OverviewPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface MobileOverviewPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface LifeAtPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopOverviewPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface MobileCareersPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface AllPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface CareersPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface MobileJobsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface MobileLifeAtPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopJobsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopPeoplePageViews {
pageViews: number;
uniquePageViews: number;
}
export interface AboutPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopAboutPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface MobilePeoplePageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopCareersPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopInsightsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface DesktopLifeAtPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface MobileInsightsPageViews {
pageViews: number;
uniquePageViews: number;
}
export interface TimeRange {
start: number;
end: number;
}

View File

@ -1,8 +1,5 @@
import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, SocialProvider
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import sharp from 'sharp';
@ -10,6 +7,7 @@ import { lookup } from 'mime-types';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
import { removeMarkdown } from '@gitroom/helpers/utils/remove.markdown';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { number, string } from 'yup';
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
identifier = 'linkedin';

View File

@ -1,4 +1,5 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
@ -10,8 +11,12 @@ import axios from 'axios';
import FormData from 'form-data';
import { timer } from '@gitroom/helpers/utils/timer';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
export class PinterestProvider extends SocialAbstract implements SocialProvider {
export class PinterestProvider
extends SocialAbstract
implements SocialProvider
{
identifier = 'pinterest';
name = 'Pinterest';
isBetweenSteps = false;
@ -181,15 +186,12 @@ export class PinterestProvider extends SocialAbstract implements SocialProvider
while (statusCode !== 'succeeded') {
console.log('trying');
const mediafile = await (
await this.fetch(
'https://api.pinterest.com/v5/media/' + media_id,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
await this.fetch('https://api.pinterest.com/v5/media/' + media_id, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
).json();
await timer(3000);
@ -261,4 +263,68 @@ export class PinterestProvider extends SocialAbstract implements SocialProvider
return [];
}
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const until = dayjs().format('YYYY-MM-DD');
const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const {
all: { daily_metrics },
} = await (
await this.fetch(
`https://api.pinterest.com/v5/user_account/analytics?start_date=${since}&end_date=${until}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
).json();
return daily_metrics.reduce(
(acc: any, item: any) => {
if (typeof item.metrics.PIN_CLICK_RATE !== 'undefined') {
acc[0].data.push({
date: item.date,
total: item.metrics.PIN_CLICK_RATE,
});
acc[1].data.push({
date: item.date,
total: item.metrics.IMPRESSION,
});
acc[2].data.push({
date: item.date,
total: item.metrics.PIN_CLICK,
});
acc[3].data.push({
date: item.date,
total: item.metrics.ENGAGEMENT,
});
acc[4].data.push({
date: item.date,
total: item.metrics.SAVE,
});
}
return acc;
},
[
{ label: 'Pin click rate', data: [] as any[] },
{ label: 'Impressions', data: [] as any[] },
{ label: 'Pin Clicks', data: [] as any[] },
{ label: 'Engagement', data: [] as any[] },
{ label: 'Saves', data: [] as any[] },
]
);
}
}

View File

@ -1,57 +1,74 @@
export interface IAuthenticator {
authenticate(params: {code: string, codeVerifier: string, refresh?: string}): Promise<AuthTokenDetails>;
refreshToken(refreshToken: string): Promise<AuthTokenDetails>;
generateAuthUrl(refresh?: string): Promise<GenerateAuthUrlResponse>;
authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}): Promise<AuthTokenDetails>;
refreshToken(refreshToken: string): Promise<AuthTokenDetails>;
generateAuthUrl(refresh?: string): Promise<GenerateAuthUrlResponse>;
analytics?(id: string, accessToken: string, date: number): Promise<AnalyticsData[]>;
}
export interface AnalyticsData {
label: string;
data: Array<{ total: string; date: string }>;
percentageChange: number;
}
export type GenerateAuthUrlResponse = {
url: string,
codeVerifier: string,
state: string
}
url: string;
codeVerifier: string;
state: string;
};
export type AuthTokenDetails = {
id: string;
name: string;
accessToken: string; // The obtained access token
refreshToken?: string; // The refresh token, if applicable
expiresIn?: number; // The duration in seconds for which the access token is valid
picture?: string;
username: string;
id: string;
name: string;
accessToken: string; // The obtained access token
refreshToken?: string; // The refresh token, if applicable
expiresIn?: number; // The duration in seconds for which the access token is valid
picture?: string;
username: string;
};
export interface ISocialMediaIntegration {
post(id: string, accessToken: string, postDetails: PostDetails[]): Promise<PostResponse[]>; // Schedules a new post
post(
id: string,
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]>; // Schedules a new post
}
export type PostResponse = {
id: string; // The db internal id of the post
postId: string; // The ID of the scheduled post returned by the platform
releaseURL: string; // The URL of the post on the platform
status: string; // Status of the operation or initial post status
id: string; // The db internal id of the post
postId: string; // The ID of the scheduled post returned by the platform
releaseURL: string; // The URL of the post on the platform
status: string; // Status of the operation or initial post status
};
export type PostDetails<T = any> = {
id: string;
message: string;
settings: T;
media?: MediaContent[];
poll?: PollDetails;
id: string;
message: string;
settings: T;
media?: MediaContent[];
poll?: PollDetails;
};
export type PollDetails = {
options: string[]; // Array of poll options
duration: number; // Duration in hours for which the poll will be active
}
export type MediaContent = {
type: 'image' | 'video'; // Type of the media content
url: string; // URL of the media file, if it's already hosted somewhere
path: string;
options: string[]; // Array of poll options
duration: number; // Duration in hours for which the poll will be active
};
export interface SocialProvider extends IAuthenticator, ISocialMediaIntegration {
identifier: string;
name: string;
isBetweenSteps: boolean;
}
export type MediaContent = {
type: 'image' | 'video'; // Type of the media content
url: string; // URL of the media file, if it's already hosted somewhere
path: string;
};
export interface SocialProvider
extends IAuthenticator,
ISocialMediaIntegration {
identifier: string;
name: string;
isBetweenSteps: boolean;
}

View File

@ -73,103 +73,18 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
codeVerifier: string;
refresh?: string;
}) {
const getAccessToken = await (
await this.fetch(
'https://graph.facebook.com/v20.0/oauth/access_token' +
`?client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/facebook${
params.refresh ? `?refresh=${params.refresh}` : ''
}`
)}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
`&code=${params.code}`
)
).json();
const { access_token } = await (
await this.fetch(
'https://graph.facebook.com/v20.0/oauth/access_token' +
'?grant_type=fb_exchange_token' +
`&client_id=${process.env.FACEBOOK_APP_ID}` +
`&client_secret=${process.env.FACEBOOK_APP_SECRET}` +
`&fb_exchange_token=${getAccessToken.access_token}&fields=access_token,expires_in`
)
).json();
if (params.refresh) {
const information = await this.fetchPageInformation(
access_token,
params.refresh
);
return {
id: information.id,
name: information.name,
accessToken: information.access_token,
refreshToken: information.access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: information.picture,
username: information.username,
};
}
const {
id,
name,
picture: {
data: { url },
},
} = await (
await this.fetch(
`https://graph.facebook.com/v19.0/me?fields=id,name,picture&access_token=${access_token}`
)
).json();
console.log(params);
return {
id,
name,
accessToken: access_token,
refreshToken: access_token,
id: '',
name: '',
accessToken: '',
refreshToken: '',
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: url,
picture: '',
username: '',
};
}
async pages(accessToken: string) {
const { data } = await (
await this.fetch(
`https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,picture.type(large)&access_token=${accessToken}`
)
).json();
return data;
}
async fetchPageInformation(accessToken: string, pageId: string) {
const {
id,
name,
access_token,
username,
picture: {
data: { url },
},
} = await (
await this.fetch(
`https://graph.facebook.com/v20.0/${pageId}?fields=username,access_token,name,picture.type(large)&access_token=${accessToken}`
)
).json();
return {
id,
name,
access_token,
picture: url,
username,
};
}
async post(
id: string,
accessToken: string,

View File

@ -1,4 +1,5 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
@ -8,9 +9,11 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
import * as console from 'node:console';
import axios from 'axios';
import axios, { all } from 'axios';
import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import * as process from 'node:process';
import dayjs from 'dayjs';
const clientAndYoutube = () => {
const client = new google.auth.OAuth2({
@ -25,13 +28,19 @@ const clientAndYoutube = () => {
auth: newClient,
});
const youtubeAnalytics = (newClient: OAuth2Client) =>
google.youtubeAnalytics({
version: 'v2',
auth: newClient,
});
const oauth2 = (newClient: OAuth2Client) =>
google.oauth2({
version: 'v2',
auth: newClient,
});
return { client, youtube, oauth2 };
return { client, youtube, oauth2, youtubeAnalytics };
};
export class YoutubeProvider extends SocialAbstract implements SocialProvider {
@ -79,6 +88,8 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtubepartner',
'https://www.googleapis.com/auth/youtubepartner',
'https://www.googleapis.com/auth/yt-analytics.readonly',
],
}),
codeVerifier: makeId(10),
@ -162,4 +173,87 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
}
return [];
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const endDate = dayjs().format('YYYY-MM-DD');
const startDate = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
const { client, youtubeAnalytics } = clientAndYoutube();
client.setCredentials({ access_token: accessToken });
const youtubeClient = youtubeAnalytics(client);
const { data } = await youtubeClient.reports.query({
ids: 'channel==MINE',
startDate,
endDate,
metrics:
'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,likes,subscribersLost',
dimensions: 'day',
sort: 'day',
});
const columns = data?.columnHeaders?.map((p) => p.name)!;
const mappedData = data?.rows?.map((p) => {
return columns.reduce((acc, curr, index) => {
acc[curr!] = p[index];
return acc;
}, {} as any);
});
const acc = [] as any[];
acc.push({
label: 'Estimated Minutes Watched',
data: mappedData?.map((p: any) => ({
total: p.estimatedMinutesWatched,
date: p.day,
})),
});
acc.push({
label: 'Average View Duration',
average: true,
data: mappedData?.map((p: any) => ({
total: p.averageViewDuration,
date: p.day,
})),
});
acc.push({
label: 'Average View Percentage',
average: true,
data: mappedData?.map((p: any) => ({
total: p.averageViewPercentage,
date: p.day,
})),
});
acc.push({
label: 'Subscribers Gained',
data: mappedData?.map((p: any) => ({
total: p.subscribersGained,
date: p.day,
})),
});
acc.push({
label: 'Subscribers Lost',
data: mappedData?.map((p: any) => ({
total: p.subscribersLost,
date: p.day,
})),
});
acc.push({
label: 'Likes',
data: mappedData?.map((p: any) => ({
total: p.likes,
date: p.day,
})),
});
return acc;
}
}

View File

@ -16,9 +16,18 @@ export const Select: FC<
disableForm?: boolean;
label: string;
name: string;
hideErrors?: boolean;
}
> = (props) => {
const { label, className, disableForm, error, extraForm, ...rest } = props;
const {
label,
className,
hideErrors,
disableForm,
error,
extraForm,
...rest
} = props;
const form = useFormContext();
const err = useMemo(() => {
if (error) return error;
@ -27,7 +36,7 @@ export const Select: FC<
}, [form?.formState?.errors?.[props?.name!]?.message, error]);
return (
<div className="flex flex-col gap-[6px]">
<div className={clsx("flex flex-col", label ? 'gap-[6px]' : '')}>
<div className={`${interClass} text-[14px]`}>{label}</div>
<select
{...(disableForm ? {} : form.register(props.name, extraForm))}
@ -37,7 +46,9 @@ export const Select: FC<
)}
{...rest}
/>
<div className="text-red-400 text-[12px]">{err || <>&nbsp;</>}</div>
{!hideErrors && (
<div className="text-red-400 text-[12px]">{err || <>&nbsp;</>}</div>
)}
</div>
);
};

69
package-lock.json generated
View File

@ -49,6 +49,7 @@
"bcrypt": "^5.1.1",
"bufferutil": "^4.0.8",
"bullmq": "^5.1.5",
"cache-manager-redis-store": "^2.0.0",
"chart.js": "^4.4.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@ -119,6 +120,7 @@
"@swc/cli": "~0.1.62",
"@swc/core": "~1.3.85",
"@testing-library/react": "14.0.0",
"@types/cache-manager-redis-store": "^2.0.4",
"@types/cookie-parser": "^1.4.6",
"@types/jest": "^29.4.0",
"@types/node": "18.16.9",
@ -12907,6 +12909,22 @@
"@types/node": "*"
}
},
"node_modules/@types/cache-manager": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-4.0.6.tgz",
"integrity": "sha512-8qL93MF05/xrzFm/LSPtzNEOE1eQF3VwGHAcQEylgp5hDSTe41jtFwbSYAPfyYcVa28y1vYSjIt0c1fLLUiC/Q==",
"dev": true
},
"node_modules/@types/cache-manager-redis-store": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/cache-manager-redis-store/-/cache-manager-redis-store-2.0.4.tgz",
"integrity": "sha512-EG4ac1KsUr07uv6N/O0X1OaQBNVKShVUxn+GwJQQpUkTEi4+KJl6yvqfwc4uTPT1+pwfKRgQhCoHQQCd/ObkZQ==",
"dev": true,
"dependencies": {
"@types/cache-manager": "*",
"@types/redis": "^2.8.0"
}
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@ -13324,6 +13342,15 @@
"@types/react": "*"
}
},
"node_modules/@types/redis": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/remove-markdown": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@types/remove-markdown/-/remove-markdown-0.3.4.tgz",
@ -15981,6 +16008,43 @@
"promise-coalesce": "^1.1.2"
}
},
"node_modules/cache-manager-redis-store": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-2.0.0.tgz",
"integrity": "sha512-bWLWlUg6nCYHiJLCCYxY2MgvwvKnvlWwrbuynrzpjEIhfArD2GC9LtutIHFEPeyGVQN6C+WEw+P3r+BFBwhswg==",
"dependencies": {
"redis": "^3.0.2"
},
"engines": {
"node": ">= 8.3"
}
},
"node_modules/cache-manager-redis-store/node_modules/denque": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cache-manager-redis-store/node_modules/redis": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
"integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
"dependencies": {
"denque": "^1.5.0",
"redis-commands": "^1.7.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-redis"
}
},
"node_modules/cache-manager/node_modules/lru-cache": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz",
@ -33636,6 +33700,11 @@
"@redis/time-series": "1.0.5"
}
},
"node_modules/redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",

View File

@ -53,6 +53,7 @@
"bcrypt": "^5.1.1",
"bufferutil": "^4.0.8",
"bullmq": "^5.1.5",
"cache-manager-redis-store": "^2.0.0",
"chart.js": "^4.4.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@ -123,6 +124,7 @@
"@swc/cli": "~0.1.62",
"@swc/core": "~1.3.85",
"@testing-library/react": "14.0.0",
"@types/cache-manager-redis-store": "^2.0.4",
"@types/cookie-parser": "^1.4.6",
"@types/jest": "^29.4.0",
"@types/node": "18.16.9",

View File