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'
+ }`}
+ >
+

+
+ ))}
+
+
+
+
+
+
+ );
+};
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],
},
}),
}