From 613a4285ffc53778b07bc3e319b541f140c65c59 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Mon, 16 Feb 2026 00:17:59 +0700 Subject: [PATCH] feat: post missing modal --- .../src/api/routes/posts.controller.ts | 17 +++ .../v1/public.integrations.controller.ts | 20 +++ .../src/components/launches/calendar.tsx | 44 +++++- .../launches/missing-release.modal.tsx | 129 ++++++++++++++++++ .../src/components/launches/statistics.tsx | 9 +- .../database/prisma/posts/posts.repository.ts | 13 ++ .../database/prisma/posts/posts.service.ts | 70 +++++++++- .../social/social.integrations.interface.ts | 4 + .../integrations/social/tiktok.provider.ts | 82 ++++++++--- 9 files changed, 359 insertions(+), 29 deletions(-) create mode 100644 apps/frontend/src/components/launches/missing-release.modal.tsx diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 2f4e0858..7be708ba 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -45,6 +45,23 @@ export class PostsController { return this._postsService.getStatistics(org.id, id); } + @Get('/:id/missing') + async getMissingContent( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string + ) { + return this._postsService.getMissingContent(org.id, id); + } + + @Put('/:id/release-id') + async updateReleaseId( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body('releaseId') releaseId: string + ) { + return this._postsService.updateReleaseId(org.id, id, releaseId); + } + @Post('/should-shortlink') async shouldShortlink(@Body() body: { messages: string[] }) { return { ask: this._shortLinkService.askShortLinkedin(body.messages) }; diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 2f289756..6184aff8 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -6,6 +6,7 @@ import { HttpException, Param, Post, + Put, Query, UploadedFile, UseInterceptors, @@ -268,6 +269,25 @@ export class PublicIntegrationsController { }; } + @Get('/posts/:id/missing') + async getMissingContent( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string + ) { + Sentry.metrics.count('public_api-request', 1); + return this._postsService.getMissingContent(org.id, id); + } + + @Put('/posts/:id/release-id') + async updateReleaseId( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body('releaseId') releaseId: string + ) { + Sentry.metrics.count('public_api-request', 1); + return this._postsService.updateReleaseId(org.id, id, releaseId); + } + @Get('/analytics/:integration') async getAnalytics( @GetOrgFromRequest() org: Organization, diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 88c27f38..43ce7be5 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -47,6 +47,7 @@ import { extend } from 'dayjs'; import { isUSCitizen } from './helpers/isuscitizen.utils'; import { useInterval } from '@mantine/hooks'; import { StatisticsModal } from '@gitroom/frontend/components/launches/statistics'; +import { MissingReleaseModal } from '@gitroom/frontend/components/launches/missing-release.modal'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import i18next from 'i18next'; import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.modal'; @@ -214,7 +215,26 @@ const usePostActions = (onMutate?: () => void) => { [modal, t] ); - return { editPost, deletePost, openStatistics }; + const openMissingRelease = useCallback( + (id: string) => () => { + modal.openModal({ + title: t('connect_post', 'Connect Post'), + closeOnClickOutside: true, + closeOnEscape: true, + withCloseButton: true, + classNames: { + modal: 'w-[100%] max-w-[800px]', + }, + children: ( + + ), + size: '60%', + }); + }, + [modal, t, mutate] + ); + + return { editPost, deletePost, openStatistics, openMissingRelease }; }; export const DayView = () => { @@ -452,7 +472,7 @@ export const ListView = () => { const { integrations, loading, listPosts } = useCalendar(); // Use shared post actions hook - const { editPost, deletePost, openStatistics } = usePostActions(); + const { editPost, deletePost, openStatistics, openMissingRelease } = usePostActions(); // Group posts by date const groupedPosts = useMemo(() => { @@ -502,6 +522,7 @@ export const ListView = () => { date={newDayjs(post.publishDate)} state={post.state} statistics={openStatistics(post.id)} + missingRelease={openMissingRelease(post.id)} editPost={editPost(post, false)} duplicatePost={editPost(post, true)} post={post} @@ -557,7 +578,7 @@ export const CalendarColumn: FC<{ const fetch = useFetch(); // Use shared post actions hook - const { editPost, deletePost, openStatistics } = usePostActions(); + const { editPost, deletePost, openStatistics, openMissingRelease } = usePostActions(); const postList = useMemo(() => { return posts.filter((post) => { const pList = dayjs.utc(post.publishDate).local(); @@ -820,6 +841,7 @@ export const CalendarColumn: FC<{ date={getDate} state={post.state} statistics={openStatistics(post.id)} + missingRelease={openMissingRelease(post.id)} editPost={editPost(post, false)} duplicatePost={editPost(post, true)} post={post} @@ -929,6 +951,7 @@ const CalendarItem: FC<{ duplicatePost: () => void; deletePost: () => void; statistics: () => void; + missingRelease?: () => void; integrations: Integrations[]; state: State; display: 'day' | 'week' | 'month'; @@ -952,6 +975,7 @@ const CalendarItem: FC<{ display, deletePost, showTime, + missingRelease, } = props; const { disableXAnalytics } = useVariables(); const preview = useCallback(() => { @@ -1016,7 +1040,17 @@ const CalendarItem: FC<{ {' '} {((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? ( <> - ) : ( + ) : post.releaseId === 'missing' && missingRelease ? ( +
+ +
+ ) : post.releaseId !== 'missing' ? (
+ ) : ( + <> )}{' '}
void; +}> = ({ postId, onSuccess }) => { + const t = useT(); + const fetch = useFetch(); + const modal = useModals(); + const toaster = useToaster(); + const [selected, setSelected] = useState(null); + const [saving, setSaving] = useState(false); + + const loadMissingContent = useCallback(async () => { + return (await fetch(`/posts/${postId}/missing`)).json(); + }, [postId, fetch]); + + const { data, isLoading } = useSWR( + `/posts/${postId}/missing`, + loadMissingContent + ); + + const handleSave = useCallback(async () => { + if (!selected) return; + setSaving(true); + try { + await fetch(`/posts/${postId}/release-id`, { + method: 'PUT', + body: JSON.stringify({ releaseId: selected }), + }); + onSuccess(); + modal.closeAll(); + modal.openModal({ + title: t('statistics', 'Statistics'), + closeOnClickOutside: true, + closeOnEscape: true, + withCloseButton: true, + classNames: { + modal: 'w-[100%] max-w-[1400px]', + }, + children: , + size: '80%', + }); + } catch { + toaster.show( + t('release_id_update_failed', 'Failed to connect post'), + 'warning' + ); + } finally { + setSaving(false); + } + }, [selected, postId, fetch, toaster, t, onSuccess, modal]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!data || data.length === 0) { + return ( +
+ {t( + 'no_missing_content', + 'No content found from this provider. The provider may not support this feature.' + )} +
+ ); + } + + return ( +
+
+ {t( + 'select_matching_content', + 'Select the content that matches this post:' + )} +
+
+ {data.map((item: { id: string; url: string }) => ( +
setSelected(item.id)} + className={`cursor-pointer rounded-[8px] overflow-hidden border-2 transition-all ${ + selected === item.id + ? 'border-[#612BD3] scale-[1.02]' + : 'border-transparent hover:border-textColor/20' + }`} + > + {item.id} +
+ ))} +
+
+ + +
+
+ ); +}; diff --git a/apps/frontend/src/components/launches/statistics.tsx b/apps/frontend/src/components/launches/statistics.tsx index ff136f87..16d4b47d 100644 --- a/apps/frontend/src/components/launches/statistics.tsx +++ b/apps/frontend/src/components/launches/statistics.tsx @@ -1,10 +1,11 @@ import React, { FC, Fragment, useCallback, useMemo, useState } from 'react'; -import useSWR from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; 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'; +import { MissingReleaseModal } from '@gitroom/frontend/components/launches/missing-release.modal'; interface AnalyticsData { label: string; @@ -34,7 +35,7 @@ export const StatisticsModal: FC<{ loadStatistics ); - const { data: analyticsData, isLoading: isLoadingAnalytics } = useSWR( + const { data: analyticsData, isLoading: isLoadingAnalytics, mutate: mutateAnalytics } = useSWR( `/analytics/post/${postId}?date=${dateRange}`, loadPostAnalytics, { @@ -47,6 +48,8 @@ export const StatisticsModal: FC<{ } ); + const isMissing = analyticsData && !Array.isArray(analyticsData) && analyticsData.missing; + const dateOptions = useMemo(() => { return [ { key: 7, value: t('7_days', '7 Days') }, @@ -76,6 +79,8 @@ export const StatisticsModal: FC<{
+ ) : isMissing ? ( + mutateAnalytics()} /> ) : (
{/* Post Analytics Section */} diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 26454661..12db7861 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -372,6 +372,19 @@ export class PostsRepository { }); } + updateReleaseId(id: string, orgId: string, releaseId: string) { + return this._post.model.post.update({ + where: { + id, + organizationId: orgId, + releaseId: 'missing', + }, + data: { + releaseId, + }, + }); + } + async changeState(id: string, state: State, err?: any, body?: any) { const update = await this._post.model.post.update({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 5a084c7e..39c77653 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -64,17 +64,85 @@ export class PostsService { return this._postRepository.updatePost(id, postId, releaseURL); } + async getMissingContent( + orgId: string, + postId: string, + forceRefresh = false + ): Promise<{ id: string; url: string }[]> { + const post = await this._postRepository.getPostById(postId, orgId); + if (!post || post.releaseId !== 'missing') { + return []; + } + + const integrationProvider = this._integrationManager.getSocialIntegration( + post.integration.providerIdentifier + ); + + if (!integrationProvider.missing) { + 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 []; + } + } + + try { + return await integrationProvider.missing( + getIntegration.internalId, + getIntegration.token + ); + } catch (e) { + console.log(e); + if (e instanceof RefreshToken) { + return this.getMissingContent(orgId, postId, true); + } + } + + return []; + } + + async updateReleaseId(orgId: string, postId: string, releaseId: string) { + return this._postRepository.updateReleaseId(postId, orgId, releaseId); + } + async checkPostAnalytics( orgId: string, postId: string, date: number, forceRefresh = false - ): Promise { + ): Promise { const post = await this._postRepository.getPostById(postId, orgId); if (!post || !post.releaseId) { return []; } + if (post.releaseId === 'missing') { + return { missing: true }; + } + const integrationProvider = this._integrationManager.getSocialIntegration( post.integration.providerIdentifier ); diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index bca6eb5f..29d8356b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -44,6 +44,10 @@ export interface IAuthenticator { accessToken: string, url: string ): Promise<{ url: string }>; + missing?( + id: string, + accessToken: string + ): Promise<{ id: string; url: string }[]>; } export interface AnalyticsData { diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index 9e628cfd..011dc3d8 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -369,7 +369,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { id: string, publishId: string, accessToken: string - ): Promise<{ url: string; id: number }> { + ): Promise<{ url: string; id: string }> { // eslint-disable-next-line no-constant-condition while (true) { const post = await ( @@ -396,7 +396,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { if (status === 'SEND_TO_USER_INBOX') { return { url: 'https://www.tiktok.com/messages?lang=en', - id: Math.floor(Math.random() * 1000000 + 100000), + id: 'missing', }; } @@ -703,6 +703,40 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { } } + async missing( + id: string, + accessToken: string + ): Promise<{ id: string; url: string }[]> { + try { + const videoListResponse = await this.fetch( + 'https://open.tiktokapis.com/v2/video/list/?fields=id,cover_image_url,title', + { + 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) { + return []; + } + + return videos.map((v: { id: string; cover_image_url: string }) => ({ + id: String(v.id), + url: v.cover_image_url, + })); + } catch (err) { + console.error('Error fetching TikTok missing content:', err); + return []; + } + } + async postAnalytics( integrationId: string, accessToken: string, @@ -711,27 +745,31 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { ): Promise { const today = dayjs().format('YYYY-MM-DD'); - const post = await ( - await this.fetch( - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - Authorization: `Bearer ${accessToken}`, + if (postId.indexOf('v_pub_url') > -1) { + const post = await ( + await this.fetch( + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + publish_id: postId, + }), }, - body: JSON.stringify({ - publish_id: postId, - }), - }, - '', - 0, - true - ) - ).json(); + '', + 0, + true + ) + ).json(); - if (!post?.data?.publicaly_available_post_id?.[0]) { - return []; + if (!post?.data?.publicaly_available_post_id?.[0]) { + return []; + } + + postId = post.data.publicaly_available_post_id[0]; } try { @@ -746,7 +784,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { }, body: JSON.stringify({ filters: { - video_ids: post?.data?.publicaly_available_post_id.map(String), + video_ids: [postId], }, }), }