feat: post missing modal

This commit is contained in:
Nevo David 2026-02-16 00:17:59 +07:00
parent cfa52b2336
commit 613a4285ff
9 changed files with 359 additions and 29 deletions

View file

@ -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) };

View file

@ -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,

View file

@ -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: (
<MissingReleaseModal postId={id} onSuccess={mutate} />
),
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<{
</div>{' '}
{((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? (
<></>
) : (
) : post.releaseId === 'missing' && missingRelease ? (
<div
className={clsx(
'hidden group-hover:block hover:underline cursor-pointer',
post?.tags?.[0]?.tag?.color && 'mix-blend-difference'
)}
onClick={missingRelease}
>
<Statistics />
</div>
) : post.releaseId !== 'missing' ? (
<div
className={clsx(
'hidden group-hover:block hover:underline cursor-pointer',
@ -1026,6 +1060,8 @@ const CalendarItem: FC<{
>
<Statistics />
</div>
) : (
<></>
)}{' '}
<div
className={clsx(

View file

@ -0,0 +1,129 @@
'use client';
import React, { FC, useCallback, useState } from 'react';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { Button } from '@gitroom/react/form/button';
import { StatisticsModal } from '@gitroom/frontend/components/launches/statistics';
export const MissingReleaseModal: FC<{
postId: string;
onSuccess: () => void;
}> = ({ postId, onSuccess }) => {
const t = useT();
const fetch = useFetch();
const modal = useModals();
const toaster = useToaster();
const [selected, setSelected] = useState<string | null>(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: <StatisticsModal postId={postId} />,
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 (
<div className="flex items-center justify-center py-[40px]">
<LoadingComponent />
</div>
);
}
if (!data || data.length === 0) {
return (
<div className="text-center text-textColor py-[20px]">
{t(
'no_missing_content',
'No content found from this provider. The provider may not support this feature.'
)}
</div>
);
}
return (
<div className="flex flex-col gap-[16px]">
<div className="text-[14px] text-textColor/70">
{t(
'select_matching_content',
'Select the content that matches this post:'
)}
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 gap-[10px] max-h-[400px] overflow-y-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor p-[4px]">
{data.map((item: { id: string; url: string }) => (
<div
key={item.id}
onClick={() => 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'
}`}
>
<img
src={item.url}
alt={item.id}
className="w-full aspect-square object-cover"
/>
</div>
))}
</div>
<div className="flex justify-end gap-[10px] pt-[8px] border-t border-tableBorder">
<Button
type="button"
onClick={() => modal.closeAll()}
className="bg-transparent border border-tableBorder text-textColor"
>
{t('cancel', 'Cancel')}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={!selected || saving}
loading={saving}
>
{t('connect_post', 'Connect Post')}
</Button>
</div>
</div>
);
};

View file

@ -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<{
<div className="flex items-center justify-center py-[40px]">
<LoadingComponent />
</div>
) : isMissing ? (
<MissingReleaseModal postId={postId} onSuccess={() => mutateAnalytics()} />
) : (
<div className="flex flex-col gap-[24px]">
{/* Post Analytics Section */}

View file

@ -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: {

View file

@ -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<AnalyticsData[]> {
): Promise<AnalyticsData[] | { missing: true }> {
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
);

View file

@ -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 {

View file

@ -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<AnalyticsData[]> {
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],
},
}),
}