diff --git a/apps/frontend/src/components/new-launch/add.edit.modal.tsx b/apps/frontend/src/components/new-launch/add.edit.modal.tsx index 42eef82c..b68b5d66 100644 --- a/apps/frontend/src/components/new-launch/add.edit.modal.tsx +++ b/apps/frontend/src/components/new-launch/add.edit.modal.tsx @@ -148,7 +148,7 @@ export const AddEditModalInnerInner: FC = (props) => { 0, existingData.integration, existingData.posts.map((post) => ({ - delay: 0, + delay: post.delay, content: post.content.indexOf('

') > -1 ? post.content diff --git a/apps/frontend/src/components/new-launch/delay.component.tsx b/apps/frontend/src/components/new-launch/delay.component.tsx index e8607e1d..bee7453c 100644 --- a/apps/frontend/src/components/new-launch/delay.component.tsx +++ b/apps/frontend/src/components/new-launch/delay.component.tsx @@ -1,17 +1,42 @@ 'use client'; -import React, { FC, useCallback } from 'react'; -import { DelayIcon } from '@gitroom/frontend/components/ui/icons'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { DelayIcon, DropdownArrowIcon } from '@gitroom/frontend/components/ui/icons'; import clsx from 'clsx'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; import { useShallow } from 'zustand/react/shallow'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useClickOutside } from '@mantine/hooks'; + +const delayOptions = [ + { value: 1, label: '1m' }, + { value: 2, label: '2m' }, + { value: 5, label: '5m' }, + { value: 10, label: '10m' }, + { value: 15, label: '15m' }, + { value: 30, label: '30m' }, + { value: 60, label: '1h' }, + { value: 120, label: '2h' }, +]; export const DelayComponent: FC<{ currentIndex: number; currentDelay: number; }> = ({ currentIndex, currentDelay }) => { const t = useT(); + const [isOpen, setIsOpen] = useState(false); + const [customValue, setCustomValue] = useState(''); + + const isCustomDelay = currentDelay > 0 && !delayOptions.some((opt) => opt.value === currentDelay); + + useEffect(() => { + if (isOpen && isCustomDelay) { + setCustomValue(String(currentDelay)); + } else if (isOpen && !isCustomDelay) { + setCustomValue(''); + } + }, [isOpen, isCustomDelay, currentDelay]); + const { current, setInternalDelay, setGlobalDelay } = useLaunchStore( useShallow((state) => ({ current: state.current, @@ -20,6 +45,13 @@ export const DelayComponent: FC<{ })) ); + const ref = useClickOutside(() => { + if (!isOpen) { + return; + } + setIsOpen(false); + }); + const setDelay = useCallback( (index: number) => (minutes: number) => { if (current !== 'global') { @@ -31,20 +63,92 @@ export const DelayComponent: FC<{ [currentIndex, current] ); + const handleSelectDelay = useCallback( + (minutes: number) => { + setDelay(currentIndex)(minutes); + setIsOpen(false); + }, + [currentIndex, setDelay] + ); + + const getCurrentDelayLabel = () => { + if (!currentDelay) return null; + const option = delayOptions.find((opt) => opt.value === currentDelay); + return option?.label || `${currentDelay} min`; + }; + return ( - setDelay(currentIndex)(100)} - data-tooltip-id="tooltip" - data-tooltip-content={ - !currentDelay - ? t('delay_comment', 'Delay comment') - : `Comment delayed by ${currentDelay} minutes` - } - className={clsx( - 'cursor-pointer', - currentDelay > 0 && 'bg-[#D82D7E] text-white rounded-full' +

+
setIsOpen(!isOpen)} + data-tooltip-id="tooltip" + data-tooltip-content={ + !currentDelay + ? t('delay_comment', 'Delay comment') + : `${t('delay_comment_by', 'Comment delayed by')} ${getCurrentDelayLabel()}` + } + className={clsx( + 'cursor-pointer flex items-center gap-[4px]', + currentDelay > 0 && 'bg-[#D82D7E] text-white rounded-full' + )} + > + +
+ {isOpen && ( +
+
+ {delayOptions.map((option) => ( +
handleSelectDelay(option.value)} + key={option.value} + className={clsx( + 'h-[32px] flex items-center justify-center rounded-[4px] cursor-pointer hover:bg-newBgColor text-[13px]', + currentDelay === option.value && 'bg-[#612BD3] text-white hover:bg-[#612BD3]' + )} + > + {option.label} +
+ ))} +
+
+
+ setCustomValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + placeholder="Custom min" + className={clsx( + 'flex-1 w-full h-[32px] px-[8px] rounded-[4px] bg-newBgColor border text-[13px] outline-none focus:border-[#612BD3]', + isCustomDelay ? 'border-[#612BD3]' : 'border-newTextColor/10' + )} + /> + +
+
+ {currentDelay > 0 && ( + + )} +
)} - /> +
); }; diff --git a/apps/frontend/src/components/new-launch/manage.modal.tsx b/apps/frontend/src/components/new-launch/manage.modal.tsx index 8f59d674..202daff9 100644 --- a/apps/frontend/src/components/new-launch/manage.modal.tsx +++ b/apps/frontend/src/components/new-launch/manage.modal.tsx @@ -315,6 +315,7 @@ export const ManageModal: FC = (props) => { value: post.values.map((value: any) => ({ ...(value.id ? { id: value.id } : {}), content: value.content, + delay: value.delay || 0, image: (value?.media || []).map( ({ id, path, alt, thumbnail, thumbnailTimestamp }: any) => ({ diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index e6dc8fd7..f89ecaca 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -22,7 +22,6 @@ import { organizationId, postId as postIdSearchParam, } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; -import { postWorkflow } from '@gitroom/orchestrator/workflows'; @Injectable() @Activity() @@ -43,7 +42,7 @@ export class PostActivity { for (const post of list) { await this._temporalService.client .getRawClient() - .workflow.signalWithStart('postWorkflow', { + .workflow.signalWithStart('postWorkflowV101', { workflowId: `post_${post.id}`, taskQueue: 'main', signal: 'poke', diff --git a/apps/orchestrator/src/workflows/index.ts b/apps/orchestrator/src/workflows/index.ts index 3a865864..22f5fce3 100644 --- a/apps/orchestrator/src/workflows/index.ts +++ b/apps/orchestrator/src/workflows/index.ts @@ -1,4 +1,5 @@ -export * from './post.workflow'; +export * from './post-workflows/post.workflow'; +export * from './post-workflows/post.workflow.v1.0.1'; export * from './autopost.workflow'; export * from './digest.email.workflow'; export * from './missing.post.workflow'; diff --git a/apps/orchestrator/src/workflows/post.workflow.ts b/apps/orchestrator/src/workflows/post-workflows/post.workflow.ts similarity index 100% rename from apps/orchestrator/src/workflows/post.workflow.ts rename to apps/orchestrator/src/workflows/post-workflows/post.workflow.ts diff --git a/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.1.ts b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.1.ts new file mode 100644 index 00000000..ed6a167f --- /dev/null +++ b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.1.ts @@ -0,0 +1,375 @@ +import { PostActivity } from '@gitroom/orchestrator/activities/post.activity'; +import { + ActivityFailure, + ApplicationFailure, + startChild, + proxyActivities, + sleep, + defineSignal, + setHandler, +} from '@temporalio/workflow'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { capitalize, sortBy } from 'lodash'; +import { PostResponse } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { TypedSearchAttributes } from '@temporalio/common'; +import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; + +const proxyTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, + }); +}; + +const { + getPostsList, + inAppNotification, + changeState, + updatePost, + sendWebhooks, + isCommentable, +} = proxyActivities({ + startToCloseTimeout: '10 minute', + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, +}); + +const poke = defineSignal('poke'); + +export async function postWorkflowV101({ + taskQueue, + postId, + organizationId, + postNow = false, +}: { + taskQueue: string; + postId: string; + organizationId: string; + postNow?: boolean; +}) { + // Dynamic task queue, for concurrency + const { + postSocial, + postComment, + refreshToken, + internalPlugs, + globalPlugs, + processInternalPlug, + processPlug, + } = proxyTaskQueue(taskQueue); + + let poked = false; + setHandler(poke, () => { + poked = true; + }); + + const startTime = new Date(); + // get all the posts and comments to post + const postsList = await getPostsList(organizationId, postId); + const [post] = postsList; + + // in case doesn't exists for some reason, fail it + if (!post || (!postNow && post.state !== 'QUEUE')) { + return; + } + + // if it's a repeatable post, we should ignore this. + if (!postNow) { + await sleep( + dayjs(post.publishDate).isBefore(dayjs()) + ? 0 + : dayjs(post.publishDate).diff(dayjs(), 'millisecond') + ); + } + + // if refresh is needed from last time, let's inform the user + if (post.integration?.refreshNeeded) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because you need to reconnect it. Please enable it and try again.`, + true, + false, + 'info' + ); + return; + } + + // if it's disabled, inform the user + if (post.integration?.disabled) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because it's disabled. Please enable it and try again.`, + true, + false, + 'info' + ); + return; + } + + // Do we need to post comment for this social? + const toComment = + postsList.length === 1 ? false : await isCommentable(post.integration); + + // list of all the saved results + const postsResults: PostResponse[] = []; + + // iterate over the posts + for (let i = 0; i < postsList.length; i++) { + // this is a small trick to repeat an action in case of token refresh + while (true) { + try { + // first post the main post + if (i === 0) { + postsResults.push( + ...(await postSocial(post.integration as Integration, [ + postsList[i], + ])) + ); + + // then post the comments if any + } else { + if (!toComment) { + break; + } + + if (postsList[i].delay) { + await sleep(60000 * postsList[i].delay); + } + + postsResults.push( + ...(await postComment( + postsResults[0].postId, + postsResults.length === 1 + ? undefined + : postsResults[i - 1].postId, + post.integration, + [postsList[i]] + )) + ); + } + + // mark post as successful + await updatePost( + postsList[i].id, + postsResults[i].postId, + postsResults[i].releaseURL + ); + + if (i === 0) { + // send notification on a sucessful post + await inAppNotification( + post.integration.organizationId, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )}`, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )} at ${postsResults[0].releaseURL}`, + true, + true + ); + } + + // break the current while to move to the next post + break; + } catch (err) { + // if token refresh is needed, do it and repeat + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'refresh_token' + ) { + const refresh = await refreshToken(post.integration); + if (!refresh || !refresh.accessToken) { + await changeState(postsList[0].id, 'ERROR', err, postsList); + return false; + } + + post.integration.token = refresh.accessToken; + continue; + } + + // for other errors, change state and inform the user if needed + await changeState(postsList[0].id, 'ERROR', err, postsList); + + // specific case for bad body errors + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'bad_body' + ) { + await inAppNotification( + post.organizationId, + `Error posting${i === 0 ? ' ' : ' comments '}on ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `An error occurred while posting${i === 0 ? ' ' : ' comments '}on ${ + post.integration?.providerIdentifier + }${err?.cause?.message ? `: ${err?.cause?.message}` : ``}`, + true, + false, + 'fail' + ); + return false; + } + + return false; + } + } + } + + // send webhooks for the post + await sendWebhooks( + postsResults[0].postId, + post.organizationId, + post.integration.id + ); + + // load internal plugs like repost by other users + const internalPlugsList = await internalPlugs( + post.integration, + JSON.parse(post.settings) + ); + + // load global plugs, like repost a post if it gets to a certain number of likes + const globalPlugsList = (await globalPlugs(post.integration)).reduce( + (all, current) => { + for (let i = 1; i <= current.totalRuns; i++) { + all.push({ + ...current, + delay: current.delay * i, + }); + } + + return all; + }, + [] + ); + + // Check if the post is repeatable + const repeatPost = !post.intervalInDays + ? [] + : [ + { + type: 'repeat-post', + delay: + post.intervalInDays * 24 * 60 * 60 * 1000 - + (new Date().getTime() - startTime.getTime()), + }, + ]; + + // Sort all the actions by delay, so we can process them in order + const list = sortBy( + [...internalPlugsList, ...globalPlugsList, ...repeatPost], + 'delay' + ); + + // process all the plugs in order, we are using while because in some cases we need to remove items from the list + while (list.length > 0) { + // get the next to process + const todo = list.shift(); + + // wait for the delay + await sleep(todo.delay); + + // process internal plug + if (todo.type === 'internal-plug') { + while (true) { + try { + await processInternalPlug({ ...todo, post: postsResults[0].postId }); + } catch (err) { + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'refresh_token' + ) { + const refresh = await refreshToken(post.integration); + if (!refresh || !refresh.accessToken) { + await changeState(postsList[0].id, 'ERROR', err, postsList); + return false; + } + + post.integration.token = refresh.accessToken; + continue; + } + } + break; + } + } + + // process global plug + if (todo.type === 'global') { + while (true) { + try { + const process = await processPlug({ + ...todo, + postId: postsResults[0].postId, + }); + if (process) { + const toDelete = list + .reduce((all, current, index) => { + if (current.plugId === todo.plugId) { + all.push(index); + } + + return all; + }, []) + .reverse(); + + for (const index of toDelete) { + list.splice(index, 1); + } + } + } catch (err) { + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'refresh_token' + ) { + const refresh = await refreshToken(post.integration); + if (!refresh || !refresh.accessToken) { + await changeState(postsList[0].id, 'ERROR', err, postsList); + return false; + } + + post.integration.token = refresh.accessToken; + continue; + } + } + break; + } + } + + // process repeat post in a new workflow, this is important so the other plugs can keep running + if (todo.type === 'repeat-post') { + await startChild(postWorkflowV101, { + parentClosePolicy: 'ABANDON', + args: [ + { + taskQueue, + postId, + organizationId, + postNow: true, + }, + ], + workflowId: `post_${post.id}_${makeId(10)}`, + typedSearchAttributes: new TypedSearchAttributes([ + { + key: postIdSearchParam, + value: postId, + }, + ]), + }); + } + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts index 68560944..e455aad3 100644 --- a/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts @@ -18,7 +18,6 @@ import { TemporalService } from 'nestjs-temporal-core'; import { TypedSearchAttributes } from '@temporalio/common'; import { organizationId, - postId as postIdSearchParam, } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; const parser = new Parser(); @@ -106,7 +105,7 @@ export class AutopostService { try { return this._temporalService.client .getRawClient() - ?.workflow.start('postWorkflow', { + ?.workflow.start('postWorkflowV101', { workflowId: `autopost-${id}`, taskQueue: 'main', args: [{ id, immediately: true }], @@ -286,6 +285,7 @@ export class AutopostService { value: [ { id: makeId(10), + delay: 0, content: state.description.replace(/\n/g, '\n\n') + '\n\n' + 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 937a9d87..cb2a647a 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -383,6 +383,7 @@ export class PostsRepository { } : {}), content: value.content, + delay: value.delay || 0, group: uuid, intervalInDays: inter ? +inter : null, approvedSubmitForOrder: APPROVED_SUBMIT_FOR_ORDER.NO, 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 36f56392..fe92a0e9 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -465,7 +465,7 @@ export class PostsService { await this._temporalService.client .getRawClient() - ?.workflow.start('postWorkflow', { + ?.workflow.start('postWorkflowV101', { workflowId: `post_${posts[0].id}`, taskQueue: 'main', args: [ @@ -533,7 +533,7 @@ export class PostsService { await this._temporalService.client .getRawClient() - ?.workflow.start('postWorkflow', { + ?.workflow.start('postWorkflowV101', { workflowId: `post_${getPostById.id}`, taskQueue: 'main', args: [ @@ -622,10 +622,12 @@ export class PostsService { ...toPost.list.map((l) => ({ id: '', content: l.post, + delay: 0, image: [], })), { id: '', + delay: 0, content: `Check out the full story here:\n${ body.postId || body.url }`, diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 56be7279..d3c5d771 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -385,6 +385,7 @@ model Post { organizationId String integrationId String content String + delay Int @default(0) group String title String? description String? diff --git a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts index dd9a8471..18842f4e 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts @@ -1,9 +1,25 @@ import { - ArrayMinSize, IsArray, IsBoolean, IsDateString, IsDefined, IsIn, IsNumber, IsOptional, IsString, MinLength, Validate, ValidateIf, ValidateNested + ArrayMinSize, + IsArray, + IsBoolean, + IsDateString, + IsDefined, + IsIn, + IsNumber, + IsOptional, + IsString, + MinLength, + Validate, + ValidateIf, + ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; -import { allProviders, type AllProvidersSettings, EmptySettings } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings'; +import { + allProviders, + type AllProvidersSettings, + EmptySettings, +} from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings'; import { ValidContent } from '@gitroom/helpers/utils/valid.images'; export class Integration { @@ -22,6 +38,10 @@ export class PostContent { @IsString() id: string; + @IsOptional() + @IsNumber() + delay: number; + @IsArray() @Type(() => MediaDto) @ValidateNested({ each: true })