Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion apps/orchestrator/src/activities/post.activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
);
}
Comment on lines +271 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The deduplication logic for LinkedIn posts is ineffective because the providers don't invoke the progress callback, which can lead to duplicate posts on retry.
Severity: MEDIUM

Suggested Fix

Modify the LinkedIn providers (linkedin.provider.ts and linkedin.page.provider.ts) to invoke the progress callback with the remote post ID after a successful API call. This will persist the ID, allowing the deduplication guard in postSocial to prevent duplicate posts during retries.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: apps/orchestrator/src/activities/post.activity.ts#L268-L281

Potential issue: The deduplication logic in the `postSocial` activity is ineffective for
LinkedIn posts. The guard at `post.activity.ts:230-242` depends on the `progress`
callback to persist the remote post ID. However, the LinkedIn providers
(`linkedin.provider.ts` and `linkedin.page.provider.ts`) accept this callback but never
invoke it. If a post to LinkedIn succeeds but the activity fails before returning, a
retry will bypass the deduplication check, resulting in a duplicate post being created.

);

await this._temporalService.client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstagramDto>[],
integration: Integration,
progress?: (response: PostResponse) => Promise<unknown> | unknown,
type = 'graph.facebook.com'
): Promise<PostResponse[]> {
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);
Expand Down Expand Up @@ -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);
Expand All @@ -695,7 +725,7 @@ export class InstagramProvider
userToken || accessToken
}&fields=status_code`,
undefined,
'',
'instagram-media-status',
0,
true
)
Expand All @@ -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
);
}
Comment on lines +761 to 771

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A partial failure during a multi-story Instagram post causes duplicate stories to be published upon retry due to flawed deduplication logic.
Severity: HIGH

Suggested Fix

The publishing logic should be updated to handle retries of multi-part posts. Instead of re-publishing all items, the loop should check which individual stories have already been published and skip them. This could be achieved by tracking the published state of each media item separately, rather than relying on a single releaseId for the entire batch.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts#L749-L771

Potential issue: When publishing multiple Instagram stories in a single action, the
system updates the post's `releaseId` in the database after each individual story is
successfully posted. If the process is interrupted or fails after some stories have been
published, a subsequent retry will cause duplicate posts. This happens because the
deduplication logic compares the `releaseId` from the database with the `releaseId` of
the post object, which are identical in this partial failure scenario. The check fails
to prevent a re-publish, and the entire sequence of stories is posted again from the
beginning.

Also affects:

  • apps/orchestrator/src/activities/post.activity.ts:229~242

Did we get this right? 👍 / 👎 to inform future reviews.


return [
Expand All @@ -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 [
{
Expand All @@ -778,7 +821,8 @@ export class InstagramProvider
)}&access_token=${accessToken}`,
{
method: 'POST',
}
},
'instagram-create-carousel'
)
).json();

Expand All @@ -796,7 +840,7 @@ export class InstagramProvider
userToken || accessToken
}`,
undefined,
'',
'instagram-media-status',
0,
true
)
Expand All @@ -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 [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,15 @@ export class InstagramStandaloneProvider
id: string,
accessToken: string,
postDetails: PostDetails<InstagramDto>[],
integration: Integration
integration: Integration,
progress?: (response: PostResponse) => Promise<unknown> | unknown
): Promise<PostResponse[]> {
return instagramProvider.post(
id,
accessToken,
postDetails,
integration,
progress,
'graph.instagram.com'
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,17 @@ export class LinkedinPageProvider
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
integration: Integration,
progress?: (response: PostResponse) => Promise<unknown> | unknown
): Promise<PostResponse[]> {
return super.post(id, accessToken, postDetails, integration, 'company');
return super.post(
id,
accessToken,
postDetails,
integration,
progress,
'company'
);
}

override async comment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
accessToken: string,
postDetails: PostDetails<LinkedinDto>[],
integration: Integration,
progress?: (response: PostResponse) => Promise<unknown> | unknown,
type = 'personal' as 'company' | 'personal'
): Promise<PostResponse[]> {
let processedPostDetails = postDetails;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> | unknown
): Promise<PostResponse[]>; // Schedules a new post

comment?(
Expand Down
Loading