From 772ff4ca74cf9e016fb82ea51975565ba7d2e3b3 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 12 Jan 2025 15:52:30 +0700 Subject: [PATCH] feat: short linking support --- apps/backend/src/api/api.module.ts | 2 + .../src/api/routes/posts.controller.ts | 38 ++--- .../components/launches/add.edit.model.tsx | 23 ++- .../src/components/launches/calendar.tsx | 101 +++++++++++++- .../src/components/launches/statistics.tsx | 73 ++++++++++ .../src/database/prisma/database.module.ts | 2 + .../database/prisma/posts/posts.service.ts | 23 ++- .../src/dtos/posts/create.post.dto.ts | 6 +- .../src/short-linking/providers/dub.ts | 80 +++++++++++ .../src/short-linking/providers/empty.ts | 21 +++ .../short-linking/short-linking.interface.ts | 7 + .../src/short-linking/short.link.service.ts | 131 ++++++++++++++++++ 12 files changed, 480 insertions(+), 27 deletions(-) create mode 100644 apps/frontend/src/components/launches/statistics.tsx create mode 100644 libraries/nestjs-libraries/src/short-linking/providers/dub.ts create mode 100644 libraries/nestjs-libraries/src/short-linking/providers/empty.ts create mode 100644 libraries/nestjs-libraries/src/short-linking/short-linking.interface.ts create mode 100644 libraries/nestjs-libraries/src/short-linking/short.link.service.ts diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 133758d09..c2101d58c 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -26,6 +26,7 @@ import { AgenciesController } from '@gitroom/backend/api/routes/agencies.control import { PublicController } from '@gitroom/backend/api/routes/public.controller'; import { RootController } from '@gitroom/backend/api/routes/root.controller'; import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; +import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; const authenticatedController = [ UsersController, @@ -63,6 +64,7 @@ const authenticatedController = [ CodesService, IntegrationManager, TrackService, + ShortLinkService, ], get exports() { return [...this.imports, ...this.providers]; diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 314ad5c69..81307e607 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -27,6 +27,7 @@ import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generato import { AgentGraphService } from '@gitroom/nestjs-libraries/agent/agent.graph.service'; import { Response } from 'express'; import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; +import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; @ApiTags('Posts') @Controller('/posts') @@ -35,9 +36,23 @@ export class PostsController { private _postsService: PostsService, private _starsService: StarsService, private _messagesService: MessagesService, - private _agentGraphService: AgentGraphService + private _agentGraphService: AgentGraphService, + private _shortLinkService: ShortLinkService ) {} + @Get('/:id/statistics') + async getStatistics( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string + ) { + return this._postsService.getStatistics(org.id, id); + } + + @Post('/should-shortlink') + async shouldShortlink(@Body() body: { messages: string[] }) { + return { ask: this._shortLinkService.askShortLinkedin(body.messages) }; + } + @Get('/marketplace/:id?') async getMarketplacePosts( @GetOrgFromRequest() org: Organization, @@ -61,26 +76,16 @@ export class PostsController { @GetOrgFromRequest() org: Organization, @Query() query: GetPostsDto ) { - const [posts] = await Promise.all([ - this._postsService.getPosts(org.id, query), - // this._commentsService.getAllCommentsByWeekYear( - // org.id, - // query.year, - // query.week - // ), - ]); + const posts = await this._postsService.getPosts(org.id, query); return { posts, - // comments, }; } @Get('/find-slot') - async findSlot( - @GetOrgFromRequest() org: Organization, - ) { - return {date: await this._postsService.findFreeDateTime(org.id)} + async findSlot(@GetOrgFromRequest() org: Organization) { + return { date: await this._postsService.findFreeDateTime(org.id) }; } @Get('/predict-trending') @@ -128,10 +133,7 @@ export class PostsController { @Res({ passthrough: false }) res: Response ) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); - for await (const event of this._agentGraphService.start( - org.id, - body, - )) { + for await (const event of this._agentGraphService.start(org.id, body)) { res.write(JSON.stringify(event) + '\n'); } diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index 124a32227..d3ca5897a 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -1,14 +1,12 @@ 'use client'; import React, { - ClipboardEventHandler, FC, Fragment, MouseEventHandler, useCallback, useEffect, useMemo, - useRef, ClipboardEvent, useState, memo, @@ -363,12 +361,33 @@ export const AddEditModal: FC<{ } } + const shortLinkUrl = await ( + await fetch('/posts/should-shortlink', { + method: 'POST', + body: JSON.stringify({ + messages: allKeys.flatMap((p) => + p.value.flatMap((a) => + a.content.slice(0, p.maximumCharacters || 1000000) + ) + ), + }), + }) + ).json(); + + const shortLink = !shortLinkUrl.ask + ? false + : await deleteDialog( + 'Do you want to shortlink the URLs? it will let you get statistics over clicks', + 'Yes, shortlink it!' + ); + setLoading(true); await fetch('/posts', { method: 'POST', body: JSON.stringify({ ...(postFor ? { order: postFor.id } : {}), type, + shortLink, date: dateState.utc().format('YYYY-MM-DDTHH:mm:ss'), posts: allKeys.map((p) => ({ ...p, diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 66232dbb1..2ba71d420 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -35,6 +35,7 @@ import { extend } from 'dayjs'; import { isUSCitizen } from './helpers/isuscitizen.utils'; import removeMd from 'remove-markdown'; import { useInterval } from '@mantine/hooks'; +import { StatisticsModal } from '@gitroom/frontend/components/launches/statistics'; extend(isSameOrAfter); extend(isSameOrBefore); @@ -508,6 +509,23 @@ export const CalendarColumn: FC<{ }); }, [integrations, getDate]); + const openStatistics = useCallback( + (id: string) => () => { + modal.openModal({ + closeOnClickOutside: true, + closeOnEscape: true, + withCloseButton: false, + classNames: { + modal: 'w-[100%] max-w-[1400px] bg-transparent text-textColor', + }, + children: , + size: '80%', + // title: `Adding posts for ${getDate.format('DD/MM/YYYY HH:mm')}`, + }); + }, + [] + ); + const addProvider = useAddProvider(); return ( @@ -551,6 +569,7 @@ export const CalendarColumn: FC<{ isBeforeNow={isBeforeNow} date={getDate} state={post.state} + statistics={openStatistics(post.id)} editPost={editPost(post, false)} duplicatePost={editPost(post, true)} post={post} @@ -654,13 +673,22 @@ const CalendarItem: FC<{ isBeforeNow: boolean; editPost: () => void; duplicatePost: () => void; + statistics: () => void; integrations: Integrations[]; state: State; display: 'day' | 'week' | 'month'; post: Post & { integration: Integration }; }> = memo((props) => { - const { editPost, duplicatePost, post, date, isBeforeNow, state, display } = - props; + const { + editPost, + statistics, + duplicatePost, + post, + date, + isBeforeNow, + state, + display, + } = props; const preview = useCallback(() => { window.open(`/p/` + post.id + '?share=true', '_blank'); @@ -683,18 +711,24 @@ const CalendarItem: FC<{ className={clsx('w-full flex h-full flex-1 flex-col group', 'relative')} style={{ opacity }} > -
+
- Duplicate +
- Preview + +
{' '} +
+
); }); + +const Duplicate = () => { + return ( + + + + ); +}; + +const Preview = () => { + return ( + + + + ); +}; + +export const Statistics = () => { + return ( + + + + ); +}; diff --git a/apps/frontend/src/components/launches/statistics.tsx b/apps/frontend/src/components/launches/statistics.tsx new file mode 100644 index 000000000..90e050d1f --- /dev/null +++ b/apps/frontend/src/components/launches/statistics.tsx @@ -0,0 +1,73 @@ +import React, { FC, Fragment, useCallback } from 'react'; +import { useModals } from '@mantine/modals'; +import useSWR from 'swr'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; + +export const StatisticsModal: FC<{ postId: string }> = (props) => { + const { postId } = props; + const modals = useModals(); + const fetch = useFetch(); + + const loadStatistics = useCallback(async () => { + return (await fetch(`/posts/${postId}/statistics`)).json(); + }, [postId]); + + const closeAll = useCallback(() => { + modals.closeAll(); + }, []); + + const { data, isLoading } = useSWR( + `/posts/${postId}/statistics`, + loadStatistics + ); + + return ( +
+ +

Statistics

+ {isLoading ? ( +
Loading
+ ) : ( + <> + {data.clicks.length === 0 ? ( + 'No Results' + ) : ( + <> +
+
Short Link
+
Original Link
+
Clicks
+ {data.clicks.map((p: any) => ( + +
{p.short}
+
{p.original}
+
{p.clicks}
+
+ ))} +
+ + )} + + )} +
+ ); +}; diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts index 7b5652030..9345983e9 100644 --- a/libraries/nestjs-libraries/src/database/prisma/database.module.ts +++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts @@ -28,6 +28,7 @@ import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service'; import { AgenciesRepository } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.repository'; import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; +import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; @Global() @Module({ @@ -64,6 +65,7 @@ import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; OpenaiService, EmailService, TrackService, + ShortLinkService, ], get exports() { return this.providers; 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 d4320b6e8..30c588027 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -21,6 +21,7 @@ import { timer } from '@gitroom/helpers/utils/timer'; import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import utc from 'dayjs/plugin/utc'; import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; +import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; dayjs.extend(utc); type PostWithConditionals = Post & { @@ -38,9 +39,20 @@ export class PostsService { private _messagesService: MessagesService, private _stripeService: StripeService, private _integrationService: IntegrationService, - private _mediaService: MediaService + private _mediaService: MediaService, + private _shortLinkService: ShortLinkService ) {} + async getStatistics(orgId: string, id: string) { + const getPost = await this.getPostsRecursively(id, true, orgId, true); + const content = getPost.map((p) => p.content); + const shortLinksTracking = await this._shortLinkService.getStatistics(content); + + return { + clicks: shortLinksTracking + } + } + async getPostsRecursively( id: string, includeIntegration = false, @@ -554,6 +566,14 @@ export class PostsService { async createPost(orgId: string, body: CreatePostDto) { const postList = []; for (const post of body.posts) { + const messages = post.value.map(p => p.content); + const updateContent = !body.shortLink ? messages : await this._shortLinkService.convertTextToShortLinks(orgId, messages); + + post.value = post.value.map((p, i) => ({ + ...p, + content: updateContent[i], + })); + const { previousPost, posts } = await this._postRepository.createOrUpdatePost( body.type, @@ -757,6 +777,7 @@ export class PostsService { type: 'draft', date: randomDate, order: '', + shortLink: false, posts: [ { group, 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 20bcd2481..3965f7103 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts @@ -1,5 +1,5 @@ import { - ArrayMinSize, IsArray, IsDateString, IsDefined, IsIn, IsOptional, IsString, MinLength, ValidateIf, ValidateNested + ArrayMinSize, IsArray, IsBoolean, IsDateString, IsDefined, IsIn, IsOptional, IsString, MinLength, ValidateIf, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto'; @@ -89,6 +89,10 @@ export class CreatePostDto { @IsString() order: string; + @IsDefined() + @IsBoolean() + shortLink: boolean; + @IsDefined() @IsDateString() date: string; diff --git a/libraries/nestjs-libraries/src/short-linking/providers/dub.ts b/libraries/nestjs-libraries/src/short-linking/providers/dub.ts new file mode 100644 index 000000000..cc61a1503 --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/dub.ts @@ -0,0 +1,80 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + +const options = { + headers: { + Authorization: `Bearer ${process.env.DUB_TOKEN}`, + 'Content-Type': 'application/json', + }, +}; + +export class Dub implements ShortLinking { + shortLinkDomain = 'dub.sh'; + + async linksStatistics(links: string[]) { + return Promise.all( + links.map(async (link) => { + const response = await ( + await fetch(`https://api.dub.co/links/info?domain=${this.shortLinkDomain}&key=${link.split('/').pop()}`, options) + ).json(); + + return { + short: link, + original: response.url, + clicks: response.clicks, + }; + }) + ); + } + + async convertLinkToShortLink(id: string, link: string) { + return ( + await ( + await fetch(`https://api.dub.co/links`, { + ...options, + method: 'POST', + body: JSON.stringify({ + url: link, + tenantId: id, + domain: this.shortLinkDomain, + }), + }) + ).json() + ).shortLink; + } + + async convertShortLinkToLink(shortLink: string) { + return await ( + await ( + await fetch( + `https://api.dub.co/links/info?domain=${shortLink}`, + options + ) + ).json() + ).url; + } + + // recursive functions that gets maximum 100 links per request if there are less than 100 links stop the recursion + async getAllLinksStatistics( + id: string, + page = 1 + ): Promise<{ short: string; original: string; clicks: string }[]> { + const response = await ( + await fetch( + `https://api.dub.co/links?tenantId=${id}&page=${page}&pageSize=100`, + options + ) + ).json(); + + const mapLinks = response.links.map((link: any) => ({ + short: link, + original: response.url, + clicks: response.clicks, + })); + + if (mapLinks.length < 100) { + return mapLinks; + } + + return [...mapLinks, ...(await this.getAllLinksStatistics(id, page + 1))]; + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/providers/empty.ts b/libraries/nestjs-libraries/src/short-linking/providers/empty.ts new file mode 100644 index 000000000..53d6bf5af --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/empty.ts @@ -0,0 +1,21 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + +export class Empty implements ShortLinking { + shortLinkDomain = 'empty'; + + async linksStatistics(links: string[]) { + return []; + } + + async convertLinkToShortLink(link: string) { + return ''; + } + + async convertShortLinkToLink(shortLink: string) { + return ''; + } + + getAllLinksStatistics(id: string, page: number): Promise<{ short: string; original: string; clicks: string }[]> { + return Promise.resolve([]); + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/short-linking.interface.ts b/libraries/nestjs-libraries/src/short-linking/short-linking.interface.ts new file mode 100644 index 000000000..74e2360aa --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/short-linking.interface.ts @@ -0,0 +1,7 @@ +export interface ShortLinking { + shortLinkDomain: string; + linksStatistics(links: string[]): Promise<{short: string; original: string, clicks: string}[]>; + convertLinkToShortLink(id: string, link: string): Promise; + convertShortLinkToLink(shortLink: string): Promise; + getAllLinksStatistics(id: string, page: number): Promise<{short: string; original: string, clicks: string}[]>; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/short-linking/short.link.service.ts b/libraries/nestjs-libraries/src/short-linking/short.link.service.ts new file mode 100644 index 000000000..cdff93ce0 --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/short.link.service.ts @@ -0,0 +1,131 @@ +import { Dub } from '@gitroom/nestjs-libraries/short-linking/providers/dub'; +import { Empty } from '@gitroom/nestjs-libraries/short-linking/providers/empty'; +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; +import { Injectable } from '@nestjs/common'; + +const getProvider = (): ShortLinking => { + if (process.env.DUB_TOKEN) { + return new Dub(); + } + + return new Empty(); +}; + +@Injectable() +export class ShortLinkService { + static provider = getProvider(); + + askShortLinkedin(messages: string[]): boolean { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return false; + } + + const mergeMessages = messages.join(' '); + const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/g; + const urls = mergeMessages.match(urlRegex); + if (!urls) { + // No URLs found, return the original text + return false; + } + + return urls.some((url) => url.indexOf(ShortLinkService.provider.shortLinkDomain) === -1); + } + + async convertTextToShortLinks(id: string, messages: string[]) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return messages; + } + + const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/g; + return Promise.all( + messages.map(async (text) => { + const urls = text.match(urlRegex); + if (!urls) { + // No URLs found, return the original text + return text; + } + + const replacementMap: Record = {}; + + // Process each URL asynchronously + await Promise.all( + urls.map(async (url) => { + if (url.indexOf(ShortLinkService.provider.shortLinkDomain) === -1) { + replacementMap[url] = + await ShortLinkService.provider.convertLinkToShortLink(id, url); + } else { + replacementMap[url] = url; // Keep the original URL if it matches the prefix + } + }) + ); + + // Replace the URLs in the text with their replacements + return text.replace(urlRegex, (url) => replacementMap[url]); + }) + ); + } + + async convertShortLinksToLinks(messages: string[]) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return messages; + } + + const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/g; + return Promise.all( + messages.map(async (text) => { + const urls = text.match(urlRegex); + if (!urls) { + // No URLs found, return the original text + return text; + } + + const replacementMap: Record = {}; + + // Process each URL asynchronously + await Promise.all( + urls.map(async (url) => { + if (url.indexOf(ShortLinkService.provider.shortLinkDomain) > -1) { + replacementMap[url] = + await ShortLinkService.provider.convertShortLinkToLink(url); + } else { + replacementMap[url] = url; // Keep the original URL if it matches the prefix + } + }) + ); + + // Replace the URLs in the text with their replacements + return text.replace(urlRegex, (url) => replacementMap[url]); + }) + ); + } + + async getStatistics(messages: string[]) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return []; + } + + const mergeMessages = messages.join(' '); + const regex = new RegExp( + `https?://${ShortLinkService.provider.shortLinkDomain.replace( + '.', + '\\.' + )}/[^\\s]*`, + 'g' + ); + const urls = mergeMessages.match(regex); + if (!urls) { + // No URLs found, return the original text + return []; + } + + return ShortLinkService.provider.linksStatistics(urls); + } + + async getAllLinks(id: string) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return []; + } + + return ShortLinkService.provider.getAllLinksStatistics(id, 1); + } +}