diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index 781d789010..2d2e592c38 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -220,6 +220,27 @@ export class PostActivity { integration.providerIdentifier ); + // If an earlier attempt of this activity already published the post (the + // remote id is persisted at the publish boundary via the progress + // callback below), don't publish again — retrying after a post-publish + // failure used to create duplicates. A releaseId equal to the one the + // workflow loaded before running this activity means the current cycle + // hasn't published yet (repeatable posts re-run with the same post id). + const [firstPost] = posts; + if (firstPost) { + const current = await this._postService.getPostReleaseId(firstPost.id); + if (current?.releaseId && current.releaseId !== firstPost.releaseId) { + return [ + { + id: firstPost.id, + postId: current.releaseId, + releaseURL: current.releaseURL || '', + status: 'success', + }, + ]; + } + } + const newPosts = await this._postService.updateTags( integration.organizationId, posts @@ -247,7 +268,17 @@ export class PostActivity { ), })) ), - integration + integration, + async (response) => { + // Persist the remote id the moment the platform confirms the + // publish, so a crash or retry after this point cannot publish a + // duplicate. + await this._postService.updatePost( + response.id, + response.postId, + response.releaseURL + ); + } ); await this._temporalService.client 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 2a3b2b2059..375a220070 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -402,6 +402,18 @@ export class PostsRepository { }); } + getPostReleaseId(id: string) { + return this._post.model.post.findUnique({ + where: { + id, + }, + select: { + releaseId: true, + releaseURL: true, + }, + }); + } + updateReleaseId(id: string, orgId: string, releaseId: string) { return 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 57f73373ec..d2ef39a520 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -81,6 +81,10 @@ export class PostsService { return this._postRepository.updatePost(id, postId, releaseURL); } + getPostReleaseId(id: string) { + return this._postRepository.getPostReleaseId(id); + } + async getMissingContent( orgId: string, postId: string, diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index c96234777f..59a23832a1 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -594,15 +594,44 @@ export class InstagramProvider }; } + // The post is already live when this is called — a failure fetching the + // permalink must not fail the publish (it used to mark a published post + // as ERROR, invite duplicate retries and email a false failure). + private async getPermalink( + type: string, + mediaId: string, + token: string, + fallback: string + ) { + try { + const { permalink } = await ( + await this.fetch( + `https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${token}`, + undefined, + 'instagram-permalink' + ) + ).json(); + return permalink || fallback; + } catch (err) { + return fallback; + } + } + async post( id: string, token: string, postDetails: PostDetails[], integration: Integration, + progress?: (response: PostResponse) => Promise | unknown, type = 'graph.facebook.com' ): Promise { const [accessToken, userToken] = token.split('___'); const [firstPost] = postDetails; + // Used when the post is live but the permalink can't be fetched — the + // publish must still be reported as a success. + const fallbackUrl = `https://www.instagram.com/${ + integration.profile || '' + }`; console.log('in progress', id); const isStory = firstPost.settings.post_type === 'story'; const isTrialReel = this.assetBoolean(firstPost.settings.is_trial_reel); @@ -676,7 +705,8 @@ export class InstagramProvider `https://${type}/v20.0/${id}/media?${mediaType}${isCarousel}${collaborators}${trialParams}${audioConfiguration}&access_token=${accessToken}${caption}`, { method: 'POST', - } + }, + 'instagram-create-media' ) ).json(); console.log('in progress2', id); @@ -695,7 +725,7 @@ export class InstagramProvider userToken || accessToken }&fields=status_code`, undefined, - '', + 'instagram-media-status', 0, true ) @@ -719,19 +749,25 @@ export class InstagramProvider `https://${type}/v20.0/${id}/media_publish?creation_id=${mediaCreationId}&access_token=${accessToken}&field=id`, { method: 'POST', - } + }, + 'instagram-media-publish' ) ).json(); lastMediaId = mediaId; - const { permalink } = await ( - await this.fetch( - `https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${ - userToken || accessToken - }` - ) - ).json(); - lastPermalink = permalink; + await progress?.({ + id: firstPost.id, + postId: mediaId, + releaseURL: fallbackUrl, + status: 'success', + }); + + lastPermalink = await this.getPermalink( + type, + mediaId, + userToken || accessToken, + fallbackUrl + ); } return [ @@ -748,17 +784,24 @@ export class InstagramProvider `https://${type}/v20.0/${id}/media_publish?creation_id=${medias[0]}&access_token=${accessToken}&field=id`, { method: 'POST', - } + }, + 'instagram-media-publish' ) ).json(); - const { permalink } = await ( - await this.fetch( - `https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${ - userToken || accessToken - }` - ) - ).json(); + await progress?.({ + id: firstPost.id, + postId: mediaId, + releaseURL: fallbackUrl, + status: 'success', + }); + + const permalink = await this.getPermalink( + type, + mediaId, + userToken || accessToken, + fallbackUrl + ); return [ { @@ -778,7 +821,8 @@ export class InstagramProvider )}&access_token=${accessToken}`, { method: 'POST', - } + }, + 'instagram-create-carousel' ) ).json(); @@ -796,7 +840,7 @@ export class InstagramProvider userToken || accessToken }`, undefined, - '', + 'instagram-media-status', 0, true ) @@ -810,17 +854,24 @@ export class InstagramProvider `https://${type}/v20.0/${id}/media_publish?creation_id=${containerId}&access_token=${accessToken}&field=id`, { method: 'POST', - } + }, + 'instagram-media-publish' ) ).json(); - const { permalink } = await ( - await this.fetch( - `https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${ - userToken || accessToken - }` - ) - ).json(); + await progress?.({ + id: firstPost.id, + postId: mediaId, + releaseURL: fallbackUrl, + status: 'success', + }); + + const permalink = await this.getPermalink( + type, + mediaId, + userToken || accessToken, + fallbackUrl + ); return [ { diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts index 039690cea0..7c90282c9f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts @@ -180,13 +180,15 @@ export class InstagramStandaloneProvider id: string, accessToken: string, postDetails: PostDetails[], - integration: Integration + integration: Integration, + progress?: (response: PostResponse) => Promise | unknown ): Promise { return instagramProvider.post( id, accessToken, postDetails, integration, + progress, 'graph.instagram.com' ); } diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts index a87f3a6fec..d6711e70ce 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -268,9 +268,17 @@ export class LinkedinPageProvider id: string, accessToken: string, postDetails: PostDetails[], - integration: Integration + integration: Integration, + progress?: (response: PostResponse) => Promise | unknown ): Promise { - return super.post(id, accessToken, postDetails, integration, 'company'); + return super.post( + id, + accessToken, + postDetails, + integration, + progress, + 'company' + ); } override async comment( diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index 212c426128..b908c8622e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -803,6 +803,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { accessToken: string, postDetails: PostDetails[], integration: Integration, + progress?: (response: PostResponse) => Promise | unknown, type = 'personal' as 'company' | 'personal' ): Promise { let processedPostDetails = postDetails; 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 1ad3cd7fa3..27f28133d5 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -86,7 +86,11 @@ export interface ISocialMediaIntegration { id: string, accessToken: string, postDetails: PostDetails[], - integration: Integration + integration: Integration, + // Called the moment the platform confirms a publish, so the caller can + // persist the remote id before any later step can fail — a retry after + // a post-publish failure must not publish a duplicate. + progress?: (response: PostResponse) => Promise | unknown ): Promise; // Schedules a new post comment?(