diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f13cb399 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +This project is Postiz, a tool to schedule social media and chat posts to 28+ channels. +You can add posts to the calendar, they will be added into a workflow and posted at the right time. +You can find things like: +- Schedule posts +- Calendar view +- Analytics +- Team management +- Media library + +This project is a monorepo with a root only package.json of dependencies. +Made with PNPM. +We have 3 important folders + +- apps/backend - this is where the API code is (NESTJS) +- apps/orchestrator - this is temporal, it's for background jobs (NESTJS) it contains all the workflows and activities +- apps/frontend - this is the code of the frontend (Vite ReactJS) +- /libraries contains a lot of services shared between backend and orchestrator and frontend components. + +We are using only pnpm, don't use any other dependency manager. +Never install frontend components from npmjs, focus on writing native components. + +The project uses tailwind 3, before writing any component look at: +- /apps/frontend/src/app/colors.scss +- /apps/frontend/src/app/global.scss +- /apps/frontend/tailwind.config.js + +All the --color-custom* are deprecated, don't use them. + +And check other components in the system before to get the right design. + +When working on the backend we need to pass the 3 layers: +Controller >> Service >> Repository (no shortcuts) +In some cases we will have +Controller >> Mananger >> Service >> Repository. + +Most of the server logic should be inside of libs/server. +The backend repository is mostly used to write controller, and import files from libs.server. + +For the frontend follow this: +- Many of the UI components lives in /apps/frontend/src/components/ui +- Routing is in /apps/frontend/src/app +- Components are in /apps/frontend/src/components +- always use SWR to fetch stuff, and use "useFetch" hook from /libraries/helpers/src/utils/custom.fetch.tsx + +When using SWR, each one have to be in a seperate hook and must comply with react-hooks/rules-of-hooks, never put eslint-disable-next-line on it. + +It means that this is valid: +const useCommunity = () => { + return useSWR.... +} + +This is not valid: +const useCommunity = () => { + return { + communities: () => useSWR("communities", getCommunities), + providers: () => useSWR("providers", getProviders), + }; +} + +- Linting of the project can run only from the root. +- Use only pnpm. \ No newline at end of file diff --git a/apps/frontend/src/components/platform-analytics/platform.analytics.tsx b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx index 0d2c865e..21b866ee 100644 --- a/apps/frontend/src/components/platform-analytics/platform.analytics.tsx +++ b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx @@ -22,7 +22,7 @@ const allowedIntegrations = [ 'instagram', 'instagram-standalone', 'linkedin-page', - // 'tiktok', + 'tiktok', 'youtube', 'gmb', 'pinterest', @@ -86,6 +86,7 @@ export const PlatformAnalytics = () => { 'threads', 'gmb', 'x', + 'tiktok', ].indexOf(currentIntegration.identifier) !== -1 ) { arr.push({ @@ -104,6 +105,7 @@ export const PlatformAnalytics = () => { 'threads', 'gmb', 'x', + 'tiktok', ].indexOf(currentIntegration.identifier) !== -1 ) { arr.push({ diff --git a/apps/frontend/src/components/platform-analytics/render.analytics.tsx b/apps/frontend/src/components/platform-analytics/render.analytics.tsx index 55358f27..e56a6a4d 100644 --- a/apps/frontend/src/components/platform-analytics/render.analytics.tsx +++ b/apps/frontend/src/components/platform-analytics/render.analytics.tsx @@ -91,12 +91,20 @@ export const RenderAnalytics: FC<{
{p.label}
-
-
- + {p.data.length > 1 ? ( + <> +
+
+ +
+
+
{total[index]}
+ + ) : ( +
+
{total[index]}
-
-
{total[index]}
+ )}
))} diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index 2ad966a8..6f8f74c3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -1,4 +1,5 @@ import { + AnalyticsData, AuthTokenDetails, PostDetails, PostResponse, @@ -23,10 +24,12 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { isBetweenSteps = false; convertToJPEG = true; scopes = [ + 'video.list', 'user.info.basic', 'video.publish', 'video.upload', 'user.info.profile', + 'user.info.stats', ]; override maxConcurrentJob = 1; // TikTok has strict video upload limits dto = TikTokDto; @@ -538,4 +541,217 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { }, ]; } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Get user stats (follower_count, following_count, likes_count, video_count) + const userStatsResponse = await this.fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=follower_count,following_count,likes_count,video_count', + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const userStatsData = await userStatsResponse.json(); + const userStats = userStatsData?.data?.user; + + const result: AnalyticsData[] = []; + + if (userStats) { + if (userStats.follower_count !== undefined) { + result.push({ + label: 'Followers', + percentageChange: 0, + data: [{ total: String(userStats.follower_count), date: today }], + }); + } + + if (userStats.following_count !== undefined) { + result.push({ + label: 'Following', + percentageChange: 0, + data: [{ total: String(userStats.following_count), date: today }], + }); + } + + if (userStats.likes_count !== undefined) { + result.push({ + label: 'Total Likes', + percentageChange: 0, + data: [{ total: String(userStats.likes_count), date: today }], + }); + } + + if (userStats.video_count !== undefined) { + result.push({ + label: 'Videos', + percentageChange: 0, + data: [{ total: String(userStats.video_count), date: today }], + }); + } + } + + // Get recent videos and aggregate their stats + const videoListResponse = await this.fetch( + 'https://open.tiktokapis.com/v2/video/list/?fields=id', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ max_count: 20 }), + } + ); + + const videoListData = await videoListResponse.json(); + const videos = videoListData?.data?.videos; + + if (videos && videos.length > 0) { + const videoIds = videos.map((v: { id: string }) => v.id); + + // Query video details to get engagement metrics + const videoQueryResponse = await this.fetch( + 'https://open.tiktokapis.com/v2/video/query/?fields=id,like_count,comment_count,share_count,view_count', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + filters: { video_ids: videoIds }, + }), + } + ); + + const videoQueryData = await videoQueryResponse.json(); + const videoDetails = videoQueryData?.data?.videos; + + if (videoDetails && videoDetails.length > 0) { + let totalViews = 0; + let totalLikes = 0; + let totalComments = 0; + let totalShares = 0; + + for (const video of videoDetails) { + totalViews += video.view_count || 0; + totalLikes += video.like_count || 0; + totalComments += video.comment_count || 0; + totalShares += video.share_count || 0; + } + + result.push({ + label: 'Recent Views', + percentageChange: 0, + data: [{ total: String(totalViews), date: today }], + }); + + result.push({ + label: 'Recent Likes', + percentageChange: 0, + data: [{ total: String(totalLikes), date: today }], + }); + + result.push({ + label: 'Recent Comments', + percentageChange: 0, + data: [{ total: String(totalComments), date: today }], + }); + + result.push({ + label: 'Recent Shares', + percentageChange: 0, + data: [{ total: String(totalShares), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching TikTok analytics:', err); + return []; + } + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + fromDate: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Query video details using the video ID + const response = await this.fetch( + 'https://open.tiktokapis.com/v2/video/query/?fields=id,like_count,comment_count,share_count,view_count', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + filters: { video_ids: [postId] }, + }), + } + ); + + const data = await response.json(); + const video = data?.data?.videos?.[0]; + + if (!video) { + return []; + } + + const result: AnalyticsData[] = []; + + if (video.view_count !== undefined) { + result.push({ + label: 'Views', + percentageChange: 0, + data: [{ total: String(video.view_count), date: today }], + }); + } + + if (video.like_count !== undefined) { + result.push({ + label: 'Likes', + percentageChange: 0, + data: [{ total: String(video.like_count), date: today }], + }); + } + + if (video.comment_count !== undefined) { + result.push({ + label: 'Comments', + percentageChange: 0, + data: [{ total: String(video.comment_count), date: today }], + }); + } + + if (video.share_count !== undefined) { + result.push({ + label: 'Shares', + percentageChange: 0, + data: [{ total: String(video.share_count), date: today }], + }); + } + + return result; + } catch (err) { + console.error('Error fetching TikTok post analytics:', err); + return []; + } + } }