Feat: plugs

This commit is contained in:
Nevo David 2024-11-06 21:51:12 +07:00
parent 86d0d2490d
commit 74ad1410c7
20 changed files with 1023 additions and 58 deletions

View File

@ -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);
}
}

View File

@ -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 (
<>
<Plugs />
</>
);
}

View File

@ -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'
)}
>
<div
className={clsx(

View File

@ -8,16 +8,16 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useVariables } from '@gitroom/react/helpers/variable.context';
export const useMenuItems = () => {
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 (

View File

@ -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 (
<>
<textarea className="hidden" {...all}></textarea>
<CopilotTextarea
disableBranding={true}
placeholder={props.placeHolder}
value={value}
className={clsx(
'!min-h-40 !max-h-80 p-[24px] overflow-hidden bg-customColor2 outline-none rounded-[4px] border-fifth border'
)}
onChange={(e) => {
onChange({ target: { name: props.name, value: e.target.value } });
}}
autosuggestionsConfig={{
textareaPurpose: `Assist me in writing social media posts.`,
chatApiConfigs: {},
}}
/>
<div className="text-red-400 text-[12px]">
{form?.formState?.errors?.[props.name]?.message as string}
</div>
</>
);
};
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<any> = 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 (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<div className="fixed left-0 top-0 bg-primary/80 z-[300] w-full min-h-full p-4 md:p-[60px] animate-fade">
<div className="max-w-[1000px] w-full h-full bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative mx-auto">
<div className="flex flex-col">
<div className="flex-1">
<TopTitle title={`Auto Plug: ${plug.title}`} />
</div>
<button
onClick={closeAll}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
<div className="my-[20px]">{plug.description}</div>
<div>
{plug.fields.map((field) => (
<div key={field.name}>
{field.type === 'richtext' ? (
<TextArea
name={field.name}
placeHolder={field.placeholder}
/>
) : (
<Input
name={field.name}
label={field.description}
className="w-full mt-[8px] p-[8px] border border-tableBorder rounded-md text-black"
placeholder={field.placeholder}
type={field.type}
/>
)}
</div>
))}
</div>
<div className="mt-[20px]">
<Button type="submit">Activate</Button>
</div>
</div>
</div>
</form>
</FormProvider>
);
};
export const PlugItem: FC<{
plug: PlugsInterface;
addPlug: (data: any) => void;
data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
};
}> = (props) => {
const { plug, addPlug, data } = props;
const [activated, setActivated] = useState(!!data?.activated);
useEffect(() => {
setActivated(!!data?.activated);
}, [data?.activated]);
const fetch = useFetch();
const changeActivated = useCallback(
async (status: 'on' | 'off') => {
await fetch(`/integrations/plugs/${data?.id}/activate`, {
body: JSON.stringify({
status: status === 'on',
}),
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
});
setActivated(status === 'on');
},
[activated]
);
return (
<div
onClick={() => addPlug(data)}
key={plug.title}
className="w-full h-[300px] bg-customColor48 hover:bg-customColor2 hover:border-customColor48 hover:border"
>
<div key={plug.title} className="p-[16px] h-full flex flex-col flex-1">
<div className="flex">
<div className="text-[20px] mb-[8px] flex-1">{plug.title}</div>
{!!data && (
<div onClick={(e) => e.stopPropagation()}>
<Slider
value={activated ? 'on' : 'off'}
onChange={changeActivated}
fill={true}
/>
</div>
)}
</div>
<div className="flex-1">{plug.description}</div>
<Button>{!data ? 'Set Plug' : 'Edit Plug'}</Button>
</div>
</div>
);
};
export const Plug = () => {
const plug = usePlugs();
const modals = useModals();
const fetch = useFetch();
const load = useCallback(async () => {
return (await fetch(`/integrations/${plug.providerId}/plugs`)).json();
}, [plug.providerId]);
const { data, isLoading, mutate } = useSWR(`plugs-${plug.providerId}`, load);
const addEditPlug = useCallback(
(p: PlugsInterface) =>
(data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
}) => {
modals.openModal({
classNames: {
modal: 'bg-transparent text-textColor',
},
withCloseButton: false,
onClose() {
mutate();
},
size: '100%',
children: (
<PlugPop
plug={p}
data={data}
settings={{
identifier: plug.identifier,
providerId: plug.providerId,
name: plug.name,
}}
/>
),
});
},
[data]
);
if (isLoading) {
return null;
}
return (
<div className="grid grid-cols-3 gap-[30px]">
{plug.plugs.map((p) => (
<PlugItem
key={p.title + '-' + plug.providerId}
addPlug={addEditPlug(p)}
plug={p}
data={data?.find((a: any) => a.plugFunction === p.methodName)}
/>
))}
</div>
);
};

View File

@ -0,0 +1,46 @@
'use client';
import { createContext, useContext } from 'react';
export interface PlugSettings {
providerId: string;
name: string;
identifier: string;
}
export interface PlugInterface extends PlugSettings {
plugs: PlugsInterface[];
}
export interface FieldsInterface {
name: string;
type: string;
validation: string;
placeholder: string;
description: string;
}
export interface PlugsInterface {
title: string;
description: string;
runEveryMilliseconds: number;
methodName: string;
fields: FieldsInterface[];
}
export const PlugsContext = createContext<PlugInterface>({
providerId: '',
name: '',
identifier: '',
plugs: [
{
title: '',
description: '',
runEveryMilliseconds: 0,
methodName: '',
fields: [{ name: '', type: '', placeholder: '', description: '', validation: '' }],
},
],
});
export const usePlugs = () => useContext(PlugsContext);

View File

@ -0,0 +1,174 @@
'use client';
import useSWR from 'swr';
import { useCallback, useMemo, useState } from 'react';
import { capitalize, orderBy } from 'lodash';
import clsx from 'clsx';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Select } from '@gitroom/react/form/select';
import { Button } from '@gitroom/react/form/button';
import { useRouter } from 'next/navigation';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { PlugsContext } from '@gitroom/frontend/components/plugs/plugs.context';
import { Plug } from '@gitroom/frontend/components/plugs/plug';
export const Plugs = () => {
const fetch = useFetch();
const router = useRouter();
const [current, setCurrent] = useState(0);
const [refresh, setRefresh] = useState(false);
const toaster = useToaster();
const load = useCallback(async () => {
return (await (await fetch('/integrations/list')).json()).integrations;
}, []);
const load2 = useCallback(async (path: string) => {
return await (await fetch(path)).json();
}, []);
const { data: plugList, isLoading: plugLoading } = useSWR(
'/integrations/plug/list',
load2,
{
fallbackData: [],
}
);
const { data, isLoading } = useSWR('analytics-list', load, {
fallbackData: [],
});
const sortedIntegrations = useMemo(() => {
return orderBy(
data.filter((integration: any) =>
plugList?.plugs?.some(
(f: any) => f.identifier === integration.identifier
)
),
// data.filter((integration) => !integration.disabled),
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [data, plugList]);
const currentIntegration = useMemo(() => {
return sortedIntegrations[current];
}, [current, sortedIntegrations]);
const currentIntegrationPlug = useMemo(() => {
const plug = plugList?.plugs?.find(
(f: any) => f?.identifier === currentIntegration?.identifier
);
if (!plug) {
return null;
}
return {
providerId: currentIntegration.id,
...plug,
};
}, [currentIntegration, plugList]);
console.log(currentIntegrationPlug);
if (isLoading || plugLoading) {
return null;
}
if (!sortedIntegrations.length && !isLoading) {
return (
<div className="flex flex-col items-center mt-[100px] gap-[27px] text-center">
<div>
<img src="/peoplemarketplace.svg" />
</div>
<div className="text-[48px]">
Can{"'"}t show analytics yet
<br />
You have to add Social Media channels
</div>
<Button onClick={() => router.push('/launches')}>
Go to the calendar to add channels
</Button>
</div>
);
}
return (
<div className="flex gap-[30px] flex-1">
<div className="p-[16px] bg-customColor48 overflow-hidden flex w-[220px]">
<div className="flex gap-[16px] flex-col overflow-hidden">
<div className="text-[20px] mb-[8px]">Channels</div>
{sortedIntegrations.map((integration, index) => (
<div
key={integration.id}
onClick={() => {
if (integration.refreshNeeded) {
toaster.show(
'Please refresh the integration from the calendar',
'warning'
);
return;
}
setRefresh(true);
setTimeout(() => {
setRefresh(false);
}, 10);
setCurrent(index);
}}
className={clsx(
'flex gap-[8px] items-center',
currentIntegration.id !== integration.id &&
'opacity-20 hover:opacity-100 cursor-pointer'
)}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer">
<div className="bg-red-500 w-[15px] h-[15px] rounded-full left-0 -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
<div
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
</div>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<PlugsContext.Provider value={currentIntegrationPlug}>
<Plug />
</PlugsContext.Provider>
</div>
</div>
);
};

View File

@ -1,14 +1,19 @@
import { Module } from '@nestjs/common';
import { StarsController } from './stars.controller';
import {DatabaseModule} from "@gitroom/nestjs-libraries/database/prisma/database.module";
import {TrendingService} from "@gitroom/nestjs-libraries/services/trending.service";
import {PostsController} from "@gitroom/workers/app/posts.controller";
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { TrendingService } from '@gitroom/nestjs-libraries/services/trending.service';
import { PostsController } from '@gitroom/workers/app/posts.controller';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { PlugsController } from '@gitroom/workers/app/plugs.controller';
@Module({
imports: [DatabaseModule, BullMqModule],
controllers: [...!process.env.IS_GENERAL ? [StarsController] : [], PostsController],
controllers: [
...(!process.env.IS_GENERAL ? [StarsController] : []),
PostsController,
PlugsController,
],
providers: [TrendingService],
})
export class AppModule {}

View File

@ -0,0 +1,52 @@
import { Controller } from '@nestjs/common';
import { EventPattern, Transport } from '@nestjs/microservices';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
@Controller()
export class PlugsController {
constructor(
private _workerServiceProducer: BullMqClient,
private _integrationService: IntegrationService
) {}
@EventPattern('plugs', Transport.REDIS)
async plug(data: {
orgId: string;
integrationId: string;
funcName: string;
retry: number;
delay: number;
}) {
try {
await this._integrationService.startPlug(data);
if (process.env.NODE_ENV && process.env.NODE_ENV !== 'development') {
return this._workerServiceProducer.emit('plugs', {
id: data.integrationId + '-' + data.funcName,
options: {
delay: 6000, // delay,
},
payload: {
...data,
retry: data.retry,
},
});
}
} catch (e) {
if (data.retry > 3) {
return;
}
return this._workerServiceProducer.emit('plugs', {
id: data.integrationId + '-' + data.funcName,
options: {
delay: data.delay, // delay,
},
payload: {
...data,
retry: data.retry + 1,
},
});
}
}
}

View File

@ -12,7 +12,7 @@ export class PostsController {
}
@EventPattern('submit', Transport.REDIS)
async payout(data: { id: string, releaseURL: string }) {
async payout(data: { id: string; releaseURL: string }) {
return this._postsService.payout(data.id, data.releaseURL);
}
}

View File

@ -0,0 +1,25 @@
import 'reflect-metadata';
export function Plug(params: {
title: string;
description: string;
runEveryMilliseconds: number;
fields: {
name: string;
description: string;
type: string;
placeholder: string;
validation?: RegExp;
}[];
}) {
return function (target: Object, propertyKey: string | symbol) {
// Retrieve existing metadata or initialize an empty array
const existingMetadata = Reflect.getMetadata('custom:plug', target) || [];
// Add the metadata information for this method
existingMetadata.push({ methodName: propertyKey, ...params });
// Define metadata on the class prototype (so it can be retrieved from the class)
Reflect.defineMetadata('custom:plug', existingMetadata, target);
};
}

View File

@ -40,8 +40,8 @@ export class BullMqClient extends ClientProxy {
const job = await queue.add(packet.pattern, packet.data, {
jobId: packet.data.id ?? v4(),
...packet.data.options,
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: !packet.data.options.attempts,
removeOnFail: !packet.data.options.attempts,
});
try {

View File

@ -5,13 +5,15 @@ import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
@Injectable()
export class IntegrationRepository {
private storage = UploadFactory.createStorage();
constructor(
private _integration: PrismaRepository<'integration'>,
private _posts: PrismaRepository<'post'>
private _posts: PrismaRepository<'post'>,
private _plugs: PrismaRepository<'plugs'>
) {}
async setTimes(org: string, id: string, times: IntegrationTimeDto) {
@ -310,4 +312,50 @@ export class IntegrationRepository {
});
}
}
getPlugsByIntegrationId(org: string, id: string) {
return this._plugs.model.plugs.findMany({
where: {
organizationId: org,
integrationId: id,
},
});
}
createOrUpdatePlug(org: string, integrationId: string, body: PlugDto) {
return this._plugs.model.plugs.upsert({
where: {
organizationId: org,
plugFunction_integrationId: {
integrationId,
plugFunction: body.func,
},
},
create: {
integrationId,
organizationId: org,
plugFunction: body.func,
data: JSON.stringify(body.fields),
activated: true,
},
update: {
data: JSON.stringify(body.fields),
},
select: {
activated: true
}
});
}
changePlugActivation(orgId: string, plugId: string, status: boolean) {
return this._plugs.model.plugs.update({
where: {
organizationId: orgId,
id: plugId,
},
data: {
activated: !!status,
},
});
}
}

View File

@ -1,27 +1,23 @@
import {
HttpException,
HttpStatus,
Injectable,
Param,
Query,
} from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
import { AnalyticsData, SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import {
AnalyticsData,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { Integration, Organization } from '@prisma/client';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
import { simpleUpload } from '@gitroom/nestjs-libraries/upload/r2.uploader';
import axios from 'axios';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { timer } from '@gitroom/helpers/utils/timer';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Injectable()
export class IntegrationService {
@ -29,10 +25,15 @@ export class IntegrationService {
constructor(
private _integrationRepository: IntegrationRepository,
private _integrationManager: IntegrationManager,
private _notificationService: NotificationService
private _notificationService: NotificationService,
private _workerServiceProducer: BullMqClient
) {}
async setTimes(orgId: string, integrationId: string, times: IntegrationTimeDto) {
async setTimes(
orgId: string,
integrationId: string,
times: IntegrationTimeDto
) {
return this._integrationRepository.setTimes(orgId, integrationId, times);
}
@ -292,7 +293,12 @@ export class IntegrationService {
return { success: true };
}
async checkAnalytics(org: Organization, integration: string, date: string, forceRefresh = false): Promise<AnalyticsData[]> {
async checkAnalytics(
org: Organization,
integration: string,
date: string,
forceRefresh = false
): Promise<AnalyticsData[]> {
const getIntegration = await this.getIntegrationById(org.id, integration);
if (!getIntegration) {
@ -307,7 +313,10 @@ export class IntegrationService {
getIntegration.providerIdentifier
);
if (dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || forceRefresh) {
if (
dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) ||
forceRefresh
) {
const { accessToken, expiresIn, refreshToken } =
await integrationProvider.refreshToken(getIntegration.refreshToken!);
@ -367,4 +376,133 @@ export class IntegrationService {
return [];
}
getPlugsByIntegrationId(org: string, integrationId: string) {
return this._integrationRepository.getPlugsByIntegrationId(
org,
integrationId
);
}
processPlugs(
orgId: string,
integrationId: string,
delay: number,
funcName: string
) {
return this._workerServiceProducer.emit('plugs', {
id: integrationId + '-' + funcName,
options: {
delay: 0, // delay,
},
payload: {
retry: 1,
delay,
orgId,
integrationId: integrationId,
funcName: funcName,
},
});
}
async activatedPlug(
orgId: string,
integrationId: string,
funcName: string,
type: 'add' | 'remove'
) {
const loadIntegration = await this.getIntegrationById(orgId, integrationId);
const allPlugs = this._integrationManager.getAllPlugs();
const findPlug = allPlugs.find(
(p) => p.identifier === loadIntegration?.providerIdentifier!
)!;
const plug = findPlug.plugs.find((p: any) => p.methodName === funcName)!;
if (type === 'add') {
return this.processPlugs(
orgId,
integrationId,
plug.runEveryMilliseconds,
funcName
);
} else {
console.log(integrationId + '-' + funcName);
return this._workerServiceProducer.delete(
'plugs',
integrationId + '-' + funcName
);
}
}
async createOrUpdatePlug(
orgId: string,
integrationId: string,
body: PlugDto
) {
const { activated } = await this._integrationRepository.createOrUpdatePlug(
orgId,
integrationId,
body
);
if (activated) {
await this.activatedPlug(orgId, integrationId, body.func, 'add');
}
}
async changePlugActivation(orgId: string, plugId: string, status: boolean) {
const { id, integrationId, plugFunction } =
await this._integrationRepository.changePlugActivation(
orgId,
plugId,
status
);
if (status) {
await this.activatedPlug(orgId, integrationId, plugFunction, 'add');
} else {
await this.activatedPlug(orgId, integrationId, plugFunction, 'remove');
}
return { id };
}
async startPlug(data: {
orgId: string;
integrationId: string;
funcName: string;
retry: number;
delay: number;
}) {
const integration = await this.getIntegrationById(
data.orgId,
data.integrationId
);
if (!integration) {
return;
}
const plugInformation = (
await this._integrationRepository.getPlugsByIntegrationId(
data.orgId,
data.integrationId
)
).find((p) => p.plugFunction === data.funcName)!;
const plugData = JSON.parse(plugInformation.data).reduce(
(all: any, current: any) => ({
...all,
[current.name]: current.value,
}),
{}
);
const integrationInstance = this._integrationManager.getSocialIntegration(
integration.providerIdentifier
);
// @ts-ignore
return integrationInstance[data.funcName](integration, plugData);
}
}

View File

@ -29,6 +29,7 @@ model Organization {
buyerOrganization MessagesGroup[]
usedCodes UsedCodes[]
credits Credits[]
plugs Plugs[]
}
model User {
@ -264,6 +265,7 @@ model Integration {
refreshNeeded Boolean @default(false)
postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]")
customInstanceDetails String?
plugs Plugs[]
@@index([updatedAt])
@@index([deletedAt])
@ -439,6 +441,20 @@ model Messages {
@@index([deletedAt])
}
model Plugs {
id String @id @default(uuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
plugFunction String
data String
integrationId String
integration Integration @relation(fields: [integrationId], references: [id])
activated Boolean @default(true)
@@unique([plugFunction, integrationId])
@@index([organizationId])
}
enum OrderStatus {
PENDING
ACCEPTED

View File

@ -0,0 +1,23 @@
import { IsDefined, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FieldsDto {
@IsString()
@IsDefined()
name: string;
@IsString()
@IsDefined()
value: string;
}
export class PlugDto {
@IsString()
@IsDefined()
func: string;
@Type(() => FieldsDto)
@ValidateNested({ each: true })
@IsDefined()
fields: FieldsDto[];
}

View File

@ -1,3 +1,5 @@
import 'reflect-metadata';
import { Injectable } from '@nestjs/common';
import { XProvider } from '@gitroom/nestjs-libraries/integrations/social/x.provider';
import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
@ -64,6 +66,27 @@ export class IntegrationManager {
})),
};
}
getAllPlugs() {
return socialIntegrationList
.map((p) => {
return {
name: p.name,
identifier: p.identifier,
plugs: (
Reflect.getMetadata('custom:plug', p.constructor.prototype) || []
).map((p: any) => ({
...p,
fields: p.fields.map((c: any) => ({
...c,
validation: c?.validation?.toString(),
})),
})),
};
})
.filter((f) => f.plugs.length);
}
getAllowedSocialsIntegrations() {
return socialIntegrationList.map((p) => p.identifier);
}

View File

@ -9,6 +9,7 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
export class LinkedinPageProvider
extends LinkedinProvider
@ -97,17 +98,20 @@ export class LinkedinPageProvider
}
async companies(accessToken: string) {
const { elements } = await (
const { elements, ...all } = await (
await fetch(
'https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&projection=(elements*(organizationalTarget~(localizedName,vanityName,logoV2(original~:playableStreams))))',
{
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202402',
},
}
)
).json();
console.log(all);
return (elements || []).map((e: any) => ({
id: e.organizationalTarget.split(':').pop(),
page: e.organizationalTarget.split(':').pop(),
@ -124,7 +128,10 @@ export class LinkedinPageProvider
requiredId: string,
accessToken: string
): Promise<AuthTokenDetails> {
const information = await this.fetchPageInformation(accessToken, requiredId);
const information = await this.fetchPageInformation(
accessToken,
requiredId
);
return {
id: information.id,
@ -355,6 +362,41 @@ export class LinkedinPageProvider
percentageChange: 5,
}));
}
@Plug({
title: 'Auto Repost Posts',
description:
'When a post reached a certain number of likes, repost it to increase engagement',
runEveryMilliseconds: 7200000,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the post',
validation: /^\d+$/,
},
],
})
async autoAddPost(integration: Integration, fields: { likesAmount: number }) {
const a = await fetch(
`https://api.linkedin.com/rest/posts?author=${encodeURIComponent(
`urn:li:organization:${integration.internalId}`
)}&q=author&count=10&sortBy=LAST_MODIFIED`,
{
method: 'GET',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${integration.token}`,
},
}
);
console.log(await a.json());
return;
}
}
export interface Root {

View File

@ -14,6 +14,8 @@ import {
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Integration } from '@prisma/client';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { string } from 'yup';
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
identifier = 'linkedin';

View File

@ -10,6 +10,9 @@ import sharp from 'sharp';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
import removeMd from 'remove-markdown';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { string } from 'yup';
import { Integration } from '@prisma/client';
export class XProvider extends SocialAbstract implements SocialProvider {
identifier = 'x';