From 74ad1410c77f6a022f02daea92faf50aafe2a3d6 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 6 Nov 2024 21:51:12 +0700 Subject: [PATCH 1/3] Feat: plugs --- .../src/api/routes/integrations.controller.ts | 38 ++- apps/frontend/src/app/(site)/plugs/page.tsx | 19 ++ .../launches/launches.component.tsx | 6 +- .../src/components/layout/top.menu.tsx | 55 ++- apps/frontend/src/components/plugs/plug.tsx | 316 ++++++++++++++++++ .../src/components/plugs/plugs.context.ts | 46 +++ apps/frontend/src/components/plugs/plugs.tsx | 174 ++++++++++ apps/workers/src/app/app.module.ts | 13 +- apps/workers/src/app/plugs.controller.ts | 52 +++ apps/workers/src/app/posts.controller.ts | 2 +- .../helpers/src/decorators/plug.decorator.ts | 25 ++ .../src/bull-mq-transport-new/client.ts | 4 +- .../integrations/integration.repository.ts | 50 ++- .../integrations/integration.service.ts | 168 +++++++++- .../src/database/prisma/schema.prisma | 16 + .../src/dtos/plugs/plug.dto.ts | 23 ++ .../src/integrations/integration.manager.ts | 23 ++ .../social/linkedin.page.provider.ts | 46 ++- .../integrations/social/linkedin.provider.ts | 2 + .../src/integrations/social/x.provider.ts | 3 + 20 files changed, 1023 insertions(+), 58 deletions(-) create mode 100644 apps/frontend/src/app/(site)/plugs/page.tsx create mode 100644 apps/frontend/src/components/plugs/plug.tsx create mode 100644 apps/frontend/src/components/plugs/plugs.context.ts create mode 100644 apps/frontend/src/components/plugs/plugs.tsx create mode 100644 apps/workers/src/app/plugs.controller.ts create mode 100644 libraries/helpers/src/decorators/plug.decorator.ts create mode 100644 libraries/nestjs-libraries/src/dtos/plugs/plug.dto.ts diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index a394fe51..a6ea63ef 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -1,5 +1,5 @@ import { - Body, Controller, Delete, Get, Param, Post, Query, UseFilters + Body, Controller, Delete, Get, Param, Post, Put, Query, UseFilters } from '@nestjs/common'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto'; @@ -23,6 +23,7 @@ import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/ import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { NotEnoughScopes } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto'; @ApiTags('Integrations') @Controller('/integrations') @@ -352,7 +353,9 @@ export class IntegrationsController { } if (refresh && id !== refresh) { - throw new NotEnoughScopes('Please refresh the channel that needs to be refreshed'); + throw new NotEnoughScopes( + 'Please refresh the channel that needs to be refreshed' + ); } return this._integrationService.createOrUpdateIntegration( @@ -444,4 +447,35 @@ export class IntegrationsController { return this._integrationService.deleteChannel(org.id, id); } + + @Get('/plug/list') + async getPlugList() { + return { plugs: this._integrationManager.getAllPlugs() }; + } + + @Get('/:id/plugs') + async getPlugsByIntegrationId( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization + ) { + return this._integrationService.getPlugsByIntegrationId(org.id, id); + } + + @Post('/:id/plugs') + async postPlugsByIntegrationId( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization, + @Body() body: PlugDto + ) { + return this._integrationService.createOrUpdatePlug(org.id, id, body); + } + + @Put('/plugs/:id/activate') + async changePlugActivation( + @Param('id') id: string, + @GetOrgFromRequest() org: Organization, + @Body('status') status: boolean + ) { + return this._integrationService.changePlugActivation(org.id, id, status); + } } diff --git a/apps/frontend/src/app/(site)/plugs/page.tsx b/apps/frontend/src/app/(site)/plugs/page.tsx new file mode 100644 index 00000000..64a417e3 --- /dev/null +++ b/apps/frontend/src/app/(site)/plugs/page.tsx @@ -0,0 +1,19 @@ +import { Plugs } from '@gitroom/frontend/components/plugs/plugs'; + +export const dynamic = 'force-dynamic'; + +import { Metadata } from 'next'; +import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; + +export const metadata: Metadata = { + title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Plugs`, + description: '', +}; + +export default async function Index() { + return ( + <> + + + ); +} diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index 29666236..39b75cb6 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -31,6 +31,7 @@ export const LaunchesComponent = () => { const load = useCallback(async (path: string) => { return (await (await fetch(path)).json()).integrations; }, []); + const user = useUser(); const { @@ -132,7 +133,10 @@ export const LaunchesComponent = () => { 'Channel disconnected, click to reconnect.', })} key={integration.id} - className={clsx("flex gap-[8px] items-center", integration.refreshNeeded && 'cursor-pointer')} + className={clsx( + 'flex gap-[8px] items-center', + integration.refreshNeeded && 'cursor-pointer' + )} >
{ - const {isGeneral} = useVariables(); + const { isGeneral } = useVariables(); return [ ...(!isGeneral ? [ - { - name: 'Analytics', - icon: 'analytics', - path: '/analytics', - }, - ] + { + name: 'Analytics', + icon: 'analytics', + path: '/analytics', + }, + ] : []), { name: isGeneral ? 'Calendar' : 'Launches', @@ -26,32 +26,27 @@ export const useMenuItems = () => { }, ...(isGeneral ? [ - { - name: 'Analytics', - icon: 'analytics', - path: '/analytics', - }, - ] + { + name: 'Analytics', + icon: 'analytics', + path: '/analytics', + }, + ] : []), ...(!isGeneral ? [ - { - name: 'Settings', - icon: 'settings', - path: '/settings', - role: ['ADMIN', 'SUPERADMIN'], - }, - ] + { + name: 'Settings', + icon: 'settings', + path: '/settings', + role: ['ADMIN', 'SUPERADMIN'], + }, + ] : []), { - name: 'Marketplace', - icon: 'marketplace', - path: '/marketplace', - }, - { - name: 'Messages', - icon: 'messages', - path: '/messages', + name: 'Plugs', + icon: 'plugs', + path: '/plugs', }, { name: 'Billing', @@ -61,12 +56,12 @@ export const useMenuItems = () => { requireBilling: true, }, ]; -} +}; export const TopMenu: FC = () => { const path = usePathname(); const user = useUser(); - const {billingEnabled} = useVariables(); + const { billingEnabled } = useVariables(); const menuItems = useMenuItems(); return ( diff --git a/apps/frontend/src/components/plugs/plug.tsx b/apps/frontend/src/components/plugs/plug.tsx new file mode 100644 index 00000000..8982f3a9 --- /dev/null +++ b/apps/frontend/src/components/plugs/plug.tsx @@ -0,0 +1,316 @@ +'use client'; +import { + PlugSettings, + PlugsInterface, + usePlugs, +} from '@gitroom/frontend/components/plugs/plugs.context'; +import { Button } from '@gitroom/react/form/button'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import useSWR, { mutate } from 'swr'; +import { useModals } from '@mantine/modals'; +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import { + FormProvider, + SubmitHandler, + useForm, + useFormContext, +} from 'react-hook-form'; +import { Input } from '@gitroom/react/form/input'; +import { CopilotTextarea } from '@copilotkit/react-textarea'; +import clsx from 'clsx'; +import { string, object } from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Slider } from '@gitroom/react/form/slider'; +import { useToaster } from '@gitroom/react/toaster/toaster'; + +export function convertBackRegex(s: string) { + const matches = s.match(/\/(.*)\/([a-z]*)/); + const pattern = matches?.[1] || ''; + const flags = matches?.[2] || ''; + + return new RegExp(pattern, flags); +} + +export const TextArea: FC<{ name: string; placeHolder: string }> = (props) => { + const form = useFormContext(); + const { onChange, onBlur, ...all } = form.register(props.name); + const value = form.watch(props.name); + + return ( + <> + + { + onChange({ target: { name: props.name, value: e.target.value } }); + }} + autosuggestionsConfig={{ + textareaPurpose: `Assist me in writing social media posts.`, + chatApiConfigs: {}, + }} + /> +
+ {form?.formState?.errors?.[props.name]?.message as string} +
+ + ); +}; + +export const PlugPop: FC<{ + plug: PlugsInterface; + settings: PlugSettings; + data?: { + activated: boolean; + data: string; + id: string; + integrationId: string; + organizationId: string; + plugFunction: string; + }; +}> = (props) => { + const { plug, settings, data } = props; + const { closeAll } = useModals(); + const fetch = useFetch(); + const toaster = useToaster(); + + const values = useMemo(() => { + if (!data?.data) { + return {}; + } + + return JSON.parse(data.data).reduce((acc: any, current: any) => { + return { + ...acc, + [current.name]: current.value, + }; + }, {} as any); + }, []); + + const yupSchema = useMemo(() => { + return object( + plug.fields.reduce((acc, field) => { + return { + ...acc, + [field.name]: field.validation + ? string().matches(convertBackRegex(field.validation), { + message: 'Invalid value', + }) + : null, + }; + }, {}) + ); + }, []); + + const form = useForm({ + resolver: yupResolver(yupSchema), + values, + mode: 'all', + }); + + const submit: SubmitHandler = useCallback(async (data) => { + await fetch(`/integrations/${settings.providerId}/plugs`, { + method: 'POST', + body: JSON.stringify({ + func: plug.methodName, + fields: Object.keys(data).map((key) => ({ + name: key, + value: data[key], + })), + }), + }); + + toaster.show('Plug updated', 'success'); + closeAll(); + }, []); + + return ( + +
+
+
+
+
+ +
+ +
+
{plug.description}
+
+ {plug.fields.map((field) => ( +
+ {field.type === 'richtext' ? ( +