diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index c1630a46..7ad68fda 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -23,6 +23,7 @@ import { import { ApiTags } from '@nestjs/swagger'; import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service'; import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto'; +import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto'; @ApiTags('Posts') @Controller('/posts') @@ -89,6 +90,15 @@ export class PostsController { return this._postsService.createPost(org.id, body); } + @Post('/generator/draft') + @CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH]) + generatePostsDraft( + @GetOrgFromRequest() org: Organization, + @Body() body: CreateGeneratedPostsDto + ) { + return this._postsService.generatePostsDraft(org.id, body); + } + @Post('/generator') @CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH]) generatePosts( diff --git a/apps/frontend/src/app/global.css b/apps/frontend/src/app/global.css index 49c36cb4..faa4da56 100644 --- a/apps/frontend/src/app/global.css +++ b/apps/frontend/src/app/global.css @@ -283,4 +283,8 @@ html { .editor * { color: white; +} + +:empty + .existing-empty { + display: none; } \ No newline at end of file diff --git a/apps/frontend/src/components/launches/generator/generator.tsx b/apps/frontend/src/components/launches/generator/generator.tsx index d310adb1..295a5aa7 100644 --- a/apps/frontend/src/components/launches/generator/generator.tsx +++ b/apps/frontend/src/components/launches/generator/generator.tsx @@ -22,30 +22,57 @@ import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator import { Button } from '@gitroom/react/form/button'; import { PostSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import clsx from 'clsx'; -const FirstStep: FC<{ nextStep: () => void }> = (props) => { - const { nextStep } = props; +const ThirdStep: FC<{ week: number; year: number }> = (props) => { + const { week, year } = props; + + const gotToPosts = useCallback(() => { + window.location.href = `/launches?week=${week}&year=${year}`; + }, [week, year]); + return ( +
+
+ success + Your posts have been scheduled as drafts. +
+ +
+
+ ); +}; + +const SecondStep: FC<{ + posts: Array>; + url: string; + postId?: string; + nextStep: (params: { week: number; year: number }) => void; +}> = (props) => { + const { posts, nextStep, url, postId } = props; const fetch = useFetch(); - - const resolver = useMemo(() => { - return classValidatorResolver(GeneratorDto); - }, []); + const [selected, setSelected] = useState>([]); + const [loading, setLoading] = useState(false); const form = useForm({ - mode: 'all', - resolver, values: { date: dayjs().week() + '_' + dayjs().year(), - url: '', - post: undefined as undefined | string, }, }); - const [url, post] = form.watch(['url', 'post']); + const addPost = useCallback( + (index: string) => () => { + if (selected.includes(index)) { + setSelected(selected.filter((i) => i !== index)); + return; + } + setSelected([...selected, index]); + }, + [selected] + ); const list = useMemo(() => { const currentDate = dayjs(); - const generateWeeks = [...new Array(52)].map((_, i) => { + return [...new Array(52)].map((_, i) => { const week = currentDate.add(i, 'week'); return { value: week.week() + '_' + week.year(), @@ -56,30 +83,38 @@ const FirstStep: FC<{ nextStep: () => void }> = (props) => { .format('YYYY-MM-DD')})`, }; }); - - return generateWeeks; }, []); - const makeSelect = useCallback((post?: string) => { - form.setValue('post', post?.split?.(':')[1]?.split(')')?.[0]); - }, []); - - const onSubmit: SubmitHandler<{ - date: string; - url: string; - post: string | undefined; - }> = useCallback(async (value) => { - fetch('/posts/generator', { - method: 'POST', - body: JSON.stringify(value), - }); - // nextStep(); - }, []); + const createPosts: SubmitHandler<{ + date: any; + }> = useCallback( + async (values) => { + setLoading(true); + await fetch('/posts/generator/draft', { + method: 'POST', + body: JSON.stringify({ + posts: posts + .filter((_, index) => selected.includes(String(index))) + .map((po) => ({ list: po })), + url, + postId: postId ? `(post:${postId})` : undefined, + year: values.date.year, + week: values.date.week, + }), + }); + setLoading(false); + nextStep({ + week: values.date.week, + year: values.date.year, + }); + }, + [selected, postId, url] + ); return ( -
+ -
+
+
+ Click on the posts you would like to schedule. +
+ They will be saved as drafts and you can edit them later. +
+
+ {posts.map((post, index) => ( +
+ {post.length > 1 && ( +
+ a thread +
+ )} +
+
+
+ {post[0].post.split('\n\n')[0]} +
+
+
+
+ ))} +
+
+ +
+
+ + + ); +}; + +const FirstStep: FC<{ + nextStep: ( + posts: Array>, + url: string, + postId?: string + ) => void; +}> = (props) => { + const { nextStep } = props; + const fetch = useFetch(); + const [loading, setLoading] = useState(false); + const resolver = useMemo(() => { + return classValidatorResolver(GeneratorDto); + }, []); + + const form = useForm({ + mode: 'all', + resolver, + values: { + url: '', + post: undefined as undefined | string, + }, + }); + + const [url, post] = form.watch(['url', 'post']); + + const makeSelect = useCallback( + (post?: string) => { + form.setValue('post', post?.split?.(':')[1]?.split(')')?.[0]); + + if (!post && !url) { + form.setError('url', { + message: 'You need to select a post or a URL', + }); + return; + } + + if (post && url) { + form.setError('url', { + message: 'You can only have a URL or a post', + }); + return; + } + + form.setError('url', { + message: '', + }); + }, + [post, url] + ); + + const onSubmit: SubmitHandler<{ + url: string; + post: string | undefined; + }> = useCallback(async (value) => { + setLoading(true); + const data = await ( + await fetch('/posts/generator', { + method: 'POST', + body: JSON.stringify(value), + }) + ).json(); + nextStep(data.list, value.url, value.post); + setLoading(false); + }, []); + + return ( +
+ +
-
Or select from exising posts
-
- {}} - onSelect={makeSelect} - date={dayjs().add(1, 'year')} - /> +
+
+ null} + onSelect={makeSelect} + date={dayjs().add(1, 'year')} + only="article" + /> +
+
+ Or select from exising posts +
-
@@ -124,9 +284,47 @@ const FirstStep: FC<{ nextStep: () => void }> = (props) => { }; export const GeneratorPopup = () => { const [step, setStep] = useState(1); + const modals = useModals(); + const [posts, setPosts] = useState< + | { + posts: Array>; + url: string; + postId?: string; + } + | undefined + >(undefined); + + const [yearAndWeek, setYearAndWeek] = useState<{ + year: number; + week: number; + } | null>(null); + + const closeAll = useCallback(() => { + modals.closeAll(); + }, []); return (
+

Generate Posts

@@ -135,7 +333,30 @@ export const GeneratorPopup = () => {
- {step === 1 && setStep(2)} />} + {step === 1 && ( + { + setPosts({ + posts, + url, + postId, + }); + setStep(2); + }} + /> + )} + {step === 2 && ( + { + setYearAndWeek(e); + setStep(3); + }} + /> + )} + {step === 3 && ( + + )}
); }; @@ -161,7 +382,6 @@ export const GeneratorComponent = () => { modal.openModal({ title: '', withCloseButton: false, - closeOnEscape: false, classNames: { modal: 'bg-transparent text-white', }, @@ -172,7 +392,7 @@ export const GeneratorComponent = () => { return ( -
- )} -
- {!!data && data.length > 0 && ( -
- {data.map((p: any) => ( -
-
-
- - -
-
{p.integration.name}
+ {!noModal && ( +
+
+
-
{removeMd(p.content)}
-
Status: {p.state}
+
- ))} + )} + {!!data && data.length > 0 && ( +
+
+ {data.map((p: any) => ( +
+
+
+ + +
+
{p.integration.name}
+
+
{removeMd(p.content)}
+
Status: {p.state}
+
+ ))} +
+
+ )}
- )} -
-
-
+
+ ))} + ); }; 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 46efc3ad..dc979553 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -38,6 +38,7 @@ export class PostsRepository { name: true, providerIdentifier: true, picture: true, + type: true, }, }, }, @@ -62,9 +63,10 @@ export class PostsRepository { getPosts(orgId: string, query: GetPostsDto) { const date = dayjs().year(query.year).isoWeek(query.week); - const startDate = date.startOf('isoWeek').toDate(); - const endDate = date.endOf('isoWeek').toDate(); + const startDate = date.startOf('week').toDate(); + const endDate = date.endOf('week').toDate(); + console.log(startDate, endDate); return this._post.model.post.findMany({ where: { OR: [ 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 d8d26b54..c7b0dcbb 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -7,12 +7,15 @@ import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integ import { Integration, Post, Media, From } from '@prisma/client'; import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; -import { capitalize } from 'lodash'; +import { capitalize, chunk, shuffle } from 'lodash'; import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service'; import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto'; import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service'; import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; +import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; type PostWithConditionals = Post & { integration?: Integration; @@ -29,7 +32,8 @@ export class PostsService { private _messagesService: MessagesService, private _stripeService: StripeService, private _extractContentService: ExtractContentService, - private _openAiService: OpenaiService + private _openAiService: OpenaiService, + private _integrationService: IntegrationService ) {} async getPostsRecursively( @@ -262,7 +266,11 @@ export class PostsService { throw new Error('You can not add a post to this publication'); } const getOrgByOrder = await this._messagesService.getOrgByOrder(order); - const submit = await this._postRepository.submit(id, order, getOrgByOrder?.messageGroup?.buyerOrganizationId!); + const submit = await this._postRepository.submit( + id, + order, + getOrgByOrder?.messageGroup?.buyerOrganizationId! + ); const messageModel = await this._messagesService.createNewMessage( submit?.submittedForOrder?.messageGroupId || '', From.SELLER, @@ -438,13 +446,100 @@ export class PostsService { } } - async generatePosts(orgId: string, body: GeneratorDto) { - const content = await this._extractContentService.extractContent(body.url); - if (content) { - const value = await this._openAiService.extractWebsiteText(content); - return {list: value}; + async loadPostContent(postId: string) { + const post = await this._postRepository.getPostById(postId); + if (!post) { + return ''; } - return []; + return post.content; + } + + async generatePosts(orgId: string, body: GeneratorDto) { + const content = body.url + ? await this._extractContentService.extractContent(body.url) + : await this.loadPostContent(body.post); + + const value = body.url + ? await this._openAiService.extractWebsiteText(content!) + : await this._openAiService.generatePosts(content!); + return { list: value }; + } + + async generatePostsDraft(orgId: string, body: CreateGeneratedPostsDto) { + const getAllIntegrations = ( + await this._integrationService.getIntegrationsList(orgId) + ).filter((f) => !f.disabled && f.providerIdentifier !== 'reddit'); + + // const posts = chunk(body.posts, getAllIntegrations.length); + const allDates = dayjs() + .isoWeek(body.week) + .year(body.year) + .startOf('isoWeek'); + + const dates = [...new Array(7)].map((_, i) => { + return allDates.add(i, 'day').format('YYYY-MM-DD'); + }); + + const findTime = (): string => { + const totalMinutes = Math.floor(Math.random() * 144) * 10; + + // Convert total minutes to hours and minutes + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + // Format hours and minutes to always be two digits + const formattedHours = hours.toString().padStart(2, '0'); + const formattedMinutes = minutes.toString().padStart(2, '0'); + const randomDate = + shuffle(dates)[0] + 'T' + `${formattedHours}:${formattedMinutes}:00`; + + if (dayjs(randomDate).isBefore(dayjs())) { + return findTime(); + } + + return randomDate; + }; + + for (const integration of getAllIntegrations) { + for (const toPost of body.posts) { + const group = makeId(10); + const randomDate = findTime(); + + await this.createPost(orgId, { + type: 'draft', + date: randomDate, + order: '', + posts: [ + { + group, + integration: { + id: integration.id, + }, + settings: { + subtitle: '', + title: '', + tags: [], + subreddit: [], + }, + value: [ + ...toPost.list.map((l) => ({ + id: '', + content: l.post, + image: [], + })), + { + id: '', + content: `Check out the full story here:\n${ + body.postId || body.url + }`, + image: [], + }, + ], + }, + ], + }); + } + } } } diff --git a/libraries/nestjs-libraries/src/dtos/generator/create.generated.posts.dto.ts b/libraries/nestjs-libraries/src/dtos/generator/create.generated.posts.dto.ts new file mode 100644 index 00000000..da639677 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/generator/create.generated.posts.dto.ts @@ -0,0 +1,52 @@ +import { + ArrayMinSize, + IsArray, + IsDefined, + IsNumber, + IsString, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +class InnerPost { + @IsString() + @IsDefined() + post: string; +} + +class PostGroup { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InnerPost) + @IsDefined() + list: InnerPost[]; +} + +export class CreateGeneratedPostsDto { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => PostGroup) + @IsDefined() + posts: PostGroup[]; + + @IsNumber() + @IsDefined() + week: number; + + @IsNumber() + @IsDefined() + year: number; + + @IsString() + @IsDefined() + @ValidateIf((o) => !o.url) + url: string; + + @IsString() + @IsDefined() + @ValidateIf((o) => !o.url) + postId: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/generator/generator.dto.ts b/libraries/nestjs-libraries/src/dtos/generator/generator.dto.ts index 1af46b6f..2921ac87 100644 --- a/libraries/nestjs-libraries/src/dtos/generator/generator.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/generator/generator.dto.ts @@ -7,18 +7,7 @@ import { ValidateNested, } from 'class-validator'; -class Date { - @IsInt() - week: number; - - @IsInt() - year: number; -} export class GeneratorDto { - @IsDefined() - @ValidateNested() - date: Date; - @IsString() @ValidateIf((o) => !o.post) @IsUrl( diff --git a/libraries/nestjs-libraries/src/openai/extract.content.service.ts b/libraries/nestjs-libraries/src/openai/extract.content.service.ts index e6938334..bf763cc2 100644 --- a/libraries/nestjs-libraries/src/openai/extract.content.service.ts +++ b/libraries/nestjs-libraries/src/openai/extract.content.service.ts @@ -1,36 +1,90 @@ import { Injectable } from '@nestjs/common'; import { JSDOM } from 'jsdom'; +function findDepth(element: Element) { + let depth = 0; + let elementer = element; + while (elementer.parentNode) { + depth++; + // @ts-ignore + elementer = elementer.parentNode; + } + return depth; +} + @Injectable() export class ExtractContentService { async extractContent(url: string) { const load = await (await fetch(url)).text(); const dom = new JSDOM(load); - const allElements = Array.from( - dom.window.document.querySelectorAll('*') - ).filter((f) => f.tagName !== 'SCRIPT'); - const findIndex = allElements.findIndex((element) => { - return ( - ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].indexOf( - element.tagName.toLowerCase() - ) > -1 - ); - }); - if (!findIndex) { - return false; - } - - return allElements - .slice(findIndex) - .map((element) => element.textContent) + // only element that has a title + const allTitles = Array.from(dom.window.document.querySelectorAll('*')) .filter((f) => { - const trim = f?.trim(); - return (trim?.length || 0) > 0 && trim !== '\n'; + return ( + f.querySelector('h1') || + f.querySelector('h2') || + f.querySelector('h3') || + f.querySelector('h4') || + f.querySelector('h5') || + f.querySelector('h6') + ); }) - .map((f) => f?.trim()) - .join('') - .replace(/\n/g, ' ') - .replace(/ {2,}/g, ' '); + .reverse(); + + const findTheOneWithMostTitles = allTitles.reduce( + (all, current) => { + const depth = findDepth(current); + const calculate = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce( + (total, tag) => { + if (current.querySelector(tag)) { + return total + 1; + } + return total; + }, + 0 + ); + + if (calculate > all.total) { + return { total: calculate, depth, element: current }; + } + + if (depth > all.depth) { + return { total: calculate, depth, element: current }; + } + + return all; + }, + { total: 0, depth: 0, element: null as Element | null } + ); + + return findTheOneWithMostTitles?.element?.textContent?.replace(/\n/g, ' ').replace(/ {2,}/g, ' '); + // + // const allElements = Array.from( + // dom.window.document.querySelectorAll('*') + // ).filter((f) => f.tagName !== 'SCRIPT'); + // const findIndex = allElements.findIndex((element) => { + // return ( + // ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].indexOf( + // element.tagName.toLowerCase() + // ) > -1 + // ); + // }); + // + // if (!findIndex) { + // return false; + // } + // + // return allElements + // .slice(findIndex) + // .map((element) => element.textContent) + // .filter((f) => { + // const trim = f?.trim(); + // return (trim?.length || 0) > 0 && trim !== '\n'; + // }) + // .map((f) => f?.trim()) + // .join('') + // .replace(/\n/g, ' ') + // .replace(/ {2,}/g, ' '); } } diff --git a/libraries/nestjs-libraries/src/openai/openai.service.ts b/libraries/nestjs-libraries/src/openai/openai.service.ts index 0c9ca217..718e8185 100644 --- a/libraries/nestjs-libraries/src/openai/openai.service.ts +++ b/libraries/nestjs-libraries/src/openai/openai.service.ts @@ -8,6 +8,65 @@ const openai = new OpenAI({ @Injectable() export class OpenaiService { + async generatePosts(content: string) { + const posts = ( + await Promise.all([ + openai.chat.completions.create({ + messages: [ + { + role: 'assistant', + content: + 'Generate a Twitter post from the content without emojis in the following JSON format: { "post": string } put it in an array with one element', + }, + { + role: 'user', + content: content!, + }, + ], + n: 5, + temperature: 1, + model: 'gpt-4o', + }), + openai.chat.completions.create({ + messages: [ + { + role: 'assistant', + content: + 'Generate a thread for social media in the following JSON format: Array<{ "post": string }> without emojis', + }, + { + role: 'user', + content: content!, + }, + ], + n: 5, + temperature: 1, + model: 'gpt-4o', + }), + ]) + ).flatMap((p) => p.choices); + + return shuffle( + posts.map((choice) => { + const { content } = choice.message; + const start = content?.indexOf('[')!; + const end = content?.lastIndexOf(']')!; + try { + return JSON.parse( + '[' + + content + ?.slice(start + 1, end) + .replace(/\n/g, ' ') + .replace(/ {2,}/g, ' ') + + ']' + ); + } catch (e) { + console.log(content); + return []; + } + }) + ); + } async extractWebsiteText(content: string) { const websiteContent = await openai.chat.completions.create({ messages: [ @@ -26,55 +85,6 @@ export class OpenaiService { const { content: articleContent } = websiteContent.choices[0].message; - const posts = ( - await Promise.all([ - openai.chat.completions.create({ - messages: [ - { - role: 'assistant', - content: - 'Generate a Twitter post from the content without emojis in the following JSON format: { "post": string } put it in an array with one element', - }, - { - role: 'user', - content: articleContent!, - }, - ], - n: 5, - temperature: 0.7, - model: 'gpt-4o', - }), - openai.chat.completions.create({ - messages: [ - { - role: 'assistant', - content: - 'Generate a thread for social media in the following JSON format: Array<{ "post": string }> without emojis', - }, - { - role: 'user', - content: articleContent!, - }, - ], - n: 5, - temperature: 0.7, - model: 'gpt-4o', - }), - ]) - ).flatMap((p) => p.choices); - - return shuffle( - posts.map((choice) => { - const { content } = choice.message; - const start = content?.indexOf('[')!; - const end = content?.lastIndexOf(']')!; - try { - return JSON.parse('[' + content?.slice(start + 1, end) + ']'); - } catch (e) { - console.log(content); - return []; - } - }) - ); + return this.generatePosts(articleContent!); } }