feat: dev integration

This commit is contained in:
Nevo David 2024-02-20 17:49:54 +07:00
parent c215375bea
commit 23e96e8e03
61 changed files with 2027 additions and 476 deletions

View File

@ -1,48 +1,60 @@
import {MiddlewareConsumer, Module, NestModule} from '@nestjs/common';
import {AuthController} from "@gitroom/backend/api/routes/auth.controller";
import {AuthService} from "@gitroom/backend/services/auth/auth.service";
import {UsersController} from "@gitroom/backend/api/routes/users.controller";
import {AuthMiddleware} from "@gitroom/backend/services/auth/auth.middleware";
import {StripeController} from "@gitroom/backend/api/routes/stripe.controller";
import {StripeService} from "@gitroom/nestjs-libraries/services/stripe.service";
import {AnalyticsController} from "@gitroom/backend/api/routes/analytics.controller";
import {PoliciesGuard} from "@gitroom/backend/services/auth/permissions/permissions.guard";
import {PermissionsService} from "@gitroom/backend/services/auth/permissions/permissions.service";
import {IntegrationsController} from "@gitroom/backend/api/routes/integrations.controller";
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
import {SettingsController} from "@gitroom/backend/api/routes/settings.controller";
import {BullMqModule} from "@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module";
import {ioRedis} from "@gitroom/nestjs-libraries/redis/redis.service";
import {PostsController} from "@gitroom/backend/api/routes/posts.controller";
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AuthController } from '@gitroom/backend/api/routes/auth.controller';
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
import { UsersController } from '@gitroom/backend/api/routes/users.controller';
import { AuthMiddleware } from '@gitroom/backend/services/auth/auth.middleware';
import { StripeController } from '@gitroom/backend/api/routes/stripe.controller';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { AnalyticsController } from '@gitroom/backend/api/routes/analytics.controller';
import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard';
import { PermissionsService } from '@gitroom/backend/services/auth/permissions/permissions.service';
import { IntegrationsController } from '@gitroom/backend/api/routes/integrations.controller';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { SettingsController } from '@gitroom/backend/api/routes/settings.controller';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport/bull-mq.module';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { PostsController } from '@gitroom/backend/api/routes/posts.controller';
import { MediaController } from '@gitroom/backend/api/routes/media.controller';
import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module';
import {ServeStaticModule} from "@nestjs/serve-static";
const authenticatedController = [
UsersController,
AnalyticsController,
IntegrationsController,
SettingsController,
PostsController
UsersController,
AnalyticsController,
IntegrationsController,
SettingsController,
PostsController,
MediaController,
];
@Module({
imports: [BullMqModule.forRoot({
connection: ioRedis
})],
controllers: [StripeController, AuthController, ...authenticatedController],
providers: [
AuthService,
StripeService,
AuthMiddleware,
PoliciesGuard,
PermissionsService,
IntegrationManager,
],
get exports() {
return [...this.imports, ...this.providers];
}
imports: [
UploadModule,
BullMqModule.forRoot({
connection: ioRedis,
}),
ServeStaticModule.forRoot({
rootPath: process.env.UPLOAD_DIRECTORY,
serveRoot: '/' + process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY,
serveStaticOptions: {
index: false,
}
}),
],
controllers: [StripeController, AuthController, ...authenticatedController],
providers: [
AuthService,
StripeService,
AuthMiddleware,
PoliciesGuard,
PermissionsService,
IntegrationManager,
],
get exports() {
return [...this.imports, ...this.providers];
},
})
export class ApiModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(AuthMiddleware)
.forRoutes(...authenticatedController);
}
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuthMiddleware).forRoutes(...authenticatedController);
}
}

View File

@ -1,95 +1,181 @@
import {Body, Controller, Get, Param, Post} from '@nestjs/common';
import {ioRedis} from "@gitroom/nestjs-libraries/redis/redis.service";
import {ConnectIntegrationDto} from "@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto";
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
import {IntegrationService} from "@gitroom/nestjs-libraries/database/prisma/integrations/integration.service";
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {Organization} from "@prisma/client";
import {ApiKeyDto} from "@gitroom/nestjs-libraries/dtos/integrations/api.key.dto";
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto';
import { IntegrationFunctionDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.function.dto';
@Controller('/integrations')
export class IntegrationsController {
constructor(
private _integrationManager: IntegrationManager,
private _integrationService: IntegrationService
constructor(
private _integrationManager: IntegrationManager,
private _integrationService: IntegrationService
) {}
@Get('/')
getIntegration() {
return this._integrationManager.getAllIntegrations();
}
@Get('/list')
async getIntegrationList(@GetOrgFromRequest() org: Organization) {
return {
integrations: (
await this._integrationService.getIntegrationsList(org.id)
).map((p) => ({
name: p.name,
id: p.id,
picture: p.picture,
identifier: p.providerIdentifier,
type: p.type,
})),
};
}
@Get('/social/:integration')
async getIntegrationUrl(@Param('integration') integration: string) {
if (
!this._integrationManager
.getAllowedSocialsIntegrations()
.includes(integration)
) {
}
@Get('/')
getIntegration() {
return this._integrationManager.getAllIntegrations();
throw new Error('Integration not allowed');
}
@Get('/list')
async getIntegrationList(
@GetOrgFromRequest() org: Organization,
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const { codeVerifier, state, url } =
await integrationProvider.generateAuthUrl();
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
return { url };
}
@Post('/function')
async functionIntegration(
@GetOrgFromRequest() org: Organization,
@Body() body: IntegrationFunctionDto
) {
const getIntegration = await this._integrationService.getIntegrationById(
org.id,
body.id
);
if (!getIntegration) {
throw new Error('Invalid integration');
}
if (getIntegration.type === 'social') {
const integrationProvider = this._integrationManager.getSocialIntegration(
getIntegration.providerIdentifier
);
if (!integrationProvider) {
throw new Error('Invalid provider');
}
if (integrationProvider[body.name]) {
return integrationProvider[body.name](getIntegration.token, body.data);
}
throw new Error('Function not found');
}
if (getIntegration.type === 'article') {
const integrationProvider =
this._integrationManager.getArticlesIntegration(
getIntegration.providerIdentifier
);
if (!integrationProvider) {
throw new Error('Invalid provider');
}
if (integrationProvider[body.name]) {
return integrationProvider[body.name](getIntegration.token, body.data);
}
throw new Error('Function not found');
}
}
@Post('/article/:integration/connect')
async connectArticle(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Body() api: ApiKeyDto
) {
if (
!this._integrationManager
.getAllowedArticlesIntegrations()
.includes(integration)
) {
return {integrations: (await this._integrationService.getIntegrationsList(org.id)).map(p => ({name: p.name, id: p.id, picture: p.picture, identifier: p.providerIdentifier, type: p.type}))};
throw new Error('Integration not allowed');
}
@Get('/social/:integration')
async getIntegrationUrl(
@Param('integration') integration: string
if (!api) {
throw new Error('Missing api');
}
const integrationProvider =
this._integrationManager.getArticlesIntegration(integration);
const { id, name, token, picture } = await integrationProvider.authenticate(
api.api
);
if (!id) {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(
org.id,
name,
picture,
'article',
String(id),
integration,
token
);
}
@Post('/social/:integration/connect')
async connectSocialMedia(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Body() body: ConnectIntegrationDto
) {
if (
!this._integrationManager
.getAllowedSocialsIntegrations()
.includes(integration)
) {
if (!this._integrationManager.getAllowedSocialsIntegrations().includes(integration)) {
throw new Error('Integration not allowed');
}
const integrationProvider = this._integrationManager.getSocialIntegration(integration);
const {codeVerifier, state, url} = await integrationProvider.generateAuthUrl();
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
return {url};
throw new Error('Integration not allowed');
}
@Post('/article/:integration/connect')
async connectArticle(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Body() api: ApiKeyDto
) {
if (!this._integrationManager.getAllowedArticlesIntegrations().includes(integration)) {
throw new Error('Integration not allowed');
}
if (!api) {
throw new Error('Missing api');
}
const integrationProvider = this._integrationManager.getArticlesIntegration(integration);
const {id, name, token, picture} = await integrationProvider.authenticate(api.api);
if (!id) {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(org.id, name, picture,'article', String(id), integration, token);
const getCodeVerifier = await ioRedis.get(`login:${body.state}`);
if (!getCodeVerifier) {
throw new Error('Invalid state');
}
@Post('/social/:integration/connect')
async connectSocialMedia(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@Body() body: ConnectIntegrationDto
) {
if (!this._integrationManager.getAllowedSocialsIntegrations().includes(integration)) {
throw new Error('Integration not allowed');
}
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const { accessToken, expiresIn, refreshToken, id, name, picture } =
await integrationProvider.authenticate({
code: body.code,
codeVerifier: getCodeVerifier,
});
const getCodeVerifier = await ioRedis.get(`login:${body.state}`);
if (!getCodeVerifier) {
throw new Error('Invalid state');
}
const integrationProvider = this._integrationManager.getSocialIntegration(integration);
const {accessToken, expiresIn, refreshToken, id, name, picture} = await integrationProvider.authenticate({
code: body.code,
codeVerifier: getCodeVerifier
});
if (!id) {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(org.id, name, picture, 'social', String(id), integration, accessToken, refreshToken, expiresIn);
if (!id) {
throw new Error('Invalid api key');
}
return this._integrationService.createIntegration(
org.id,
name,
picture,
'social',
String(id),
integration,
accessToken,
refreshToken,
expiresIn
);
}
}

View File

@ -0,0 +1,42 @@
import {
Controller, FileTypeValidator, Get, MaxFileSizeValidator, ParseFilePipe, Post, Query, UploadedFile, UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Express } from 'express';
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {Organization} from "@prisma/client";
import {MediaService} from "@gitroom/nestjs-libraries/database/prisma/media/media.service";
@Controller('/media')
export class MediaController {
constructor(
private _mediaService: MediaService
) {
}
@Post('/')
@UseInterceptors(FileInterceptor('file'))
uploadFile(
@GetOrgFromRequest() org: Organization,
@UploadedFile(
'file',
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }),
new FileTypeValidator({ fileType: 'image/*' }),
],
})
)
file: Express.Multer.File
) {
const filePath = file.path.replace(process.env.UPLOAD_DIRECTORY, '');
return this._mediaService.saveFile(org.id, file.originalname, filePath);
}
@Get('/')
getMedia(
@GetOrgFromRequest() org: Organization,
@Query('page') page: number,
) {
return this._mediaService.getMedia(org.id, page);
}
}

View File

@ -1,8 +1,9 @@
import {Body, Controller, Post} from '@nestjs/common';
import {Body, Controller, Get, Param, Post, Put, Query} from '@nestjs/common';
import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service";
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {Organization} from "@prisma/client";
import {CreatePostDto} from "@gitroom/nestjs-libraries/dtos/posts/create.post.dto";
import {GetPostsDto} from "@gitroom/nestjs-libraries/dtos/posts/get.posts.dto";
@Controller('/posts')
export class PostsController {
@ -11,6 +12,22 @@ export class PostsController {
) {
}
@Get('/')
getPosts(
@GetOrgFromRequest() org: Organization,
@Query() query: GetPostsDto
) {
return this._postsService.getPosts(org.id, query);
}
@Get('/:id')
getPost(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
) {
return this._postsService.getPost(org.id, id);
}
@Post('/')
createPost(
@GetOrgFromRequest() org: Organization,
@ -18,4 +35,13 @@ export class PostsController {
) {
return this._postsService.createPost(org.id, body);
}
@Put('/:id/date')
changeDate(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('date') date: string
) {
return this._postsService.changeDate(org.id, id, date);
}
}

View File

@ -11,7 +11,7 @@ async function bootstrap() {
cors: {
credentials: true,
exposedHeaders: ['reload'],
origin: [process.env.FRONTEND_URL]
origin: [process.env.FRONTEND_URL],
}
});

View File

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["node"],
"types": ["node", "Multer"],
"emitDecoratorMetadata": true,
"target": "es2021"
},

View File

@ -0,0 +1,5 @@
export default async function Page() {
return (
<div>We are experiencing some difficulty, try to refresh the page</div>
)
}

View File

@ -2,75 +2,253 @@
@tailwind components;
@tailwind utilities;
body, html {
background-color: black;
body,
html {
background-color: black;
}
.box {
position: relative;
padding: 8px 24px;
position: relative;
padding: 8px 24px;
}
.box span {
position: relative;
z-index: 2;
position: relative;
z-index: 2;
}
.box:after {
border-radius: 50px;
width: 100%;
height: 100%;
left: 0;
top: 0;
content: "";
position: absolute;
background: white;
opacity: 0;
z-index: 1;
transition: all 0.3s ease-in-out;
border-radius: 50px;
width: 100%;
height: 100%;
left: 0;
top: 0;
content: '';
position: absolute;
background: white;
opacity: 0;
z-index: 1;
transition: all 0.3s ease-in-out;
}
.showbox {
color: black;
color: black;
}
.showbox:after {
opacity: 1;
background: white;
transition: all 0.3s ease-in-out;
opacity: 1;
background: white;
transition: all 0.3s ease-in-out;
}
.table1 {
width: 100%;
border-collapse: collapse;
width: 100%;
border-collapse: collapse;
}
.table1 thead {
background-color: #0F1524;
height: 44px;
font-size: 12px;
border-bottom: 1px solid #28344F;
background-color: #0f1524;
height: 44px;
font-size: 12px;
border-bottom: 1px solid #28344f;
}
.table1 thead th, .table1 tbody td {
text-align: left;
padding: 14px 24px;
.table1 thead th,
.table1 tbody td {
text-align: left;
padding: 14px 24px;
}
.table1 tbody td {
padding: 16px 24px;
font-family: Inter;
font-size: 14px;
padding: 16px 24px;
font-family: Inter;
font-size: 14px;
}
.swal2-modal {
background-color: black !important;
border: 2px solid #0B101B;
background-color: black !important;
border: 2px solid #0b101b;
}
.swal2-modal * {
color: white !important;
color: white !important;
}
.swal2-icon {
color: white !important;
border-color: white !important;
color: white !important;
border-color: white !important;
}
.swal2-confirm {
background-color: #262373 !important;
}
background-color: #262373 !important;
}
.w-md-editor-text {
min-height: 100% !important;
}
.react-tags {
position: relative;
border: 1px solid #28344f;
padding-left: 16px;
height: 44px;
border-radius: 4px;
background: #131b2c;
/* shared font styles */
font-size: 14px;
line-height: 1.2;
/* clicking anywhere will focus the input */
cursor: text;
display: flex;
align-items: center;
}
.react-tags.is-active {
border-color: #4f46e5;
}
.react-tags.is-disabled {
opacity: 0.75;
background-color: #eaeef2;
/* Prevent any clicking on the component */
pointer-events: none;
cursor: not-allowed;
}
.react-tags.is-invalid {
border-color: #fd5956;
box-shadow: 0 0 0 2px rgba(253, 86, 83, 0.25);
}
.react-tags__label {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.react-tags__list {
/* Do not use display: contents, it's too buggy */
display: inline;
padding: 0;
}
.react-tags__list-item {
display: inline;
list-style: none;
}
.react-tags__tag {
margin: 0 0.25rem 0 0;
padding: 0.15rem 0.5rem;
border: 0;
border-radius: 3px;
background: #7236f1;
/* match the font styles */
font-size: inherit;
line-height: inherit;
}
.react-tags__tag:hover {
color: #ffffff;
background-color: #4f46e5;
}
.react-tags__tag::after {
content: '';
display: inline-block;
width: 0.65rem;
height: 0.65rem;
clip-path: polygon(
10% 0,
0 10%,
40% 50%,
0 90%,
10% 100%,
50% 60%,
90% 100%,
100% 90%,
60% 50%,
100% 10%,
90% 0,
50% 40%
);
margin-left: 0.5rem;
font-size: 0.875rem;
background-color: #7c7d86;
}
.react-tags__tag:hover::after {
background-color: #ffffff;
}
.react-tags__combobox {
display: inline-block;
/* match tag layout */
/* prevents autoresize overflowing the container */
max-width: 100%;
}
.react-tags__combobox-input {
/* prevent autoresize overflowing the container */
max-width: 100%;
/* remove styles and layout from this element */
margin: 0;
padding: 0;
border: 0;
outline: none;
background: none;
/* match the font styles */
font-size: inherit;
line-height: inherit;
}
.react-tags__combobox-input::placeholder {
color: #7c7d86;
opacity: 1;
}
.react-tags__listbox {
position: absolute;
z-index: 1;
top: calc(100% + 5px);
/* Negate the border width on the container */
left: -2px;
right: -2px;
max-height: 12.5rem;
overflow-y: auto;
background: #131b2c;
border: 1px solid #afb8c1;
border-radius: 6px;
box-shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -4px,
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
}
.react-tags__listbox-option {
padding: 0.375rem 0.5rem;
}
.react-tags__listbox-option:hover {
cursor: pointer;
background: #080b13;
}
.react-tags__listbox-option:not([aria-disabled='true']).is-active {
background: #4f46e5;
color: #ffffff;
}
.react-tags__listbox-option[aria-disabled='true'] {
color: #7c7d86;
cursor: not-allowed;
pointer-events: none;
}
.react-tags__listbox-option[aria-selected='true']::after {
content: '✓';
margin-left: 0.5rem;
}
.react-tags__listbox-option[aria-selected='true']:not(.is-active)::after {
color: #4f46e5;
}
.react-tags__listbox-option-highlight {
background-color: #ffdd00;
}

View File

@ -1,4 +1,5 @@
import './global.css';
import 'react-tooltip/dist/react-tooltip.css'
import LayoutContext from "@gitroom/frontend/components/layout/layout.context";
import {ReactNode} from "react";

View File

@ -22,6 +22,7 @@ import {
useMoveToIntegration,
useMoveToIntegrationListener,
} from '@gitroom/frontend/components/launches/helpers/use.move.to.integration';
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
export const PickPlatforms: FC<{
integrations: Integrations[];
@ -157,7 +158,7 @@ export const PickPlatforms: FC<{
export const PreviewComponent: FC<{
integrations: Integrations[];
editorValue: string[];
editorValue: Array<{ id?: string; content: string }>;
}> = (props) => {
const { integrations, editorValue } = props;
const [selectedIntegrations, setSelectedIntegrations] = useState([
@ -201,7 +202,9 @@ export const AddEditModal: FC<{
>([]);
// value of each editor
const [value, setValue] = useState<string[]>(['']);
const [value, setValue] = useState<Array<{ content: string; id?: string }>>([
{ content: '' },
]);
const fetch = useFetch();
@ -217,6 +220,18 @@ export const AddEditModal: FC<{
// hook to open a new modal
const modal = useModals();
// are we in edit mode?
const existingData = useExistingData();
// if it's edit just set the current integration
useEffect(() => {
if (existingData.integration) {
setSelectedIntegrations([
integrations.find((p) => p.id === existingData.integration)!,
]);
}
}, [existingData.integration]);
// if the user exit the popup we reset the global variable with all the values
useEffect(() => {
return () => {
@ -228,7 +243,7 @@ export const AddEditModal: FC<{
const changeValue = useCallback(
(index: number) => (newValue: string) => {
return setValue((prev) => {
prev[index] = newValue;
prev[index].content = newValue;
return [...prev];
});
},
@ -239,7 +254,7 @@ export const AddEditModal: FC<{
const addValue = useCallback(
(index: number) => () => {
setValue((prev) => {
prev.splice(index + 1, 0, '');
prev.splice(index + 1, 0, { content: '' });
return [...prev];
});
},
@ -307,20 +322,22 @@ export const AddEditModal: FC<{
</svg>
</button>
<div className="flex flex-col gap-[20px]">
<PickPlatforms
integrations={integrations}
selectedIntegrations={[]}
singleSelect={false}
onChange={setSelectedIntegrations}
/>
{!showHide.hideTopEditor ? (
{!existingData.integration && (
<PickPlatforms
integrations={integrations}
selectedIntegrations={[]}
singleSelect={false}
onChange={setSelectedIntegrations}
/>
)}
{!existingData.integration && !showHide.hideTopEditor ? (
<>
{value.map((p, index) => (
<>
<MDEditor
key={`edit_${index}`}
height={value.length > 1 ? 150 : 500}
value={p}
value={p.content}
preview="edit"
// @ts-ignore
onChange={changeValue(index)}
@ -332,9 +349,11 @@ export const AddEditModal: FC<{
))}
</>
) : (
<div className="h-[100px] flex justify-center items-center bg-sixth border-tableBorder border-2">
Global Editor Hidden
</div>
!existingData.integration && (
<div className="h-[100px] flex justify-center items-center bg-sixth border-tableBorder border-2">
Global Editor Hidden
</div>
)
)}
{!!selectedIntegrations.length && (
<PreviewComponent

View File

@ -1,10 +1,14 @@
'use client';
import "reflect-metadata";
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
import utc from 'dayjs/plugin/utc';
import {createContext, FC, ReactNode, useContext, useState} from 'react';
import {createContext, FC, ReactNode, useCallback, useContext, useEffect, useMemo, useState} from 'react';
import dayjs from 'dayjs';
import useSWR from "swr";
import {useFetch} from "@gitroom/helpers/utils/custom.fetch";
import {Post, Integration} from '@prisma/client';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
@ -13,7 +17,9 @@ dayjs.extend(utc);
const CalendarContext = createContext({
currentWeek: dayjs().week(),
integrations: [] as Integrations[],
setFilters: (filters: { currentWeek: number }) => {},
posts: [] as Array<Post & {integration: Integration}>,
setFilters: (filters: { currentWeek: number, currentYear: number }) => {},
changeDate: (id: string, date: dayjs.Dayjs) => {},
});
export interface Integrations {
@ -27,11 +33,44 @@ export const CalendarWeekProvider: FC<{ children: ReactNode, integrations: Integ
children,
integrations
}) => {
const fetch = useFetch();
const [internalData, setInternalData] = useState([] as any[]);
const [filters, setFilters] = useState({
currentWeek: dayjs().week(),
currentYear: dayjs().year(),
});
const params = useMemo(() => {
return new URLSearchParams({
week: filters.currentWeek.toString(),
year: filters.currentYear.toString(),
}).toString();
}, [filters]);
const loadData = useCallback(async(url: string) => {
return (await fetch(url)).json();
}, [filters]);
const {data, isLoading} = useSWR(`/posts?${params}`, loadData);
const changeDate = useCallback((id: string, date: dayjs.Dayjs) => {
setInternalData(d => d.map((post: Post) => {
if (post.id === id) {
return {...post, publishDate: date.format('YYYY-MM-DDTHH:mm:ss')};
}
return post;
}));
}, [data, internalData]);
useEffect(() => {
if (data) {
setInternalData(data);
}
}, [data]);
return (
<CalendarContext.Provider value={{ ...filters, integrations, setFilters }}>
<CalendarContext.Provider value={{ ...filters, posts: isLoading ? [] : internalData, integrations, setFilters, changeDate }}>
{children}
</CalendarContext.Provider>
);

View File

@ -2,12 +2,18 @@
import { FC, useCallback, useMemo } from 'react';
import {
Integrations,
useCalendar,
} from '@gitroom/frontend/components/launches/calendar.context';
import dayjs from 'dayjs';
import { useModals } from '@mantine/modals';
import {AddEditModal} from "@gitroom/frontend/components/launches/add.edit.model";
import clsx from "clsx";
import { AddEditModal } from '@gitroom/frontend/components/launches/add.edit.model';
import clsx from 'clsx';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import { useDrag, useDrop } from 'react-dnd';
import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider';
import { Integration, Post } from '@prisma/client';
const days = [
'',
@ -46,44 +52,179 @@ const hours = [
'23:00',
];
const CalendarItem: FC<{
date: dayjs.Dayjs;
editPost: () => void;
integrations: Integrations[];
post: Post & { integration: Integration };
}> = (props) => {
const { editPost, post, date, integrations } = props;
const [{ opacity }, dragRef] = useDrag(
() => ({
type: 'post',
item: { id: post.id, date },
collect: (monitor) => ({
opacity: monitor.isDragging() ? 0 : 1,
}),
}),
[]
);
return (
<div
ref={dragRef}
onClick={editPost}
className="relative"
data-tooltip-id="tooltip"
style={{ opacity }}
data-tooltip-content={`${
integrations.find(
(p) => p.identifier === post.integration?.providerIdentifier
)?.name
}: ${post.content.slice(0, 100)}`}
>
<img
className="w-[20px] h-[20px] rounded-full"
src={
integrations.find(
(p) => p.identifier === post.integration?.providerIdentifier
)?.picture!
}
/>
<img
className="w-[12px] h-[12px] rounded-full absolute z-10 bottom-[0] right-0 border border-fifth"
src={`/icons/platforms/${post.integration?.providerIdentifier}.png`}
/>
</div>
);
};
const CalendarColumn: FC<{ day: number; hour: string }> = (props) => {
const { day, hour } = props;
const week = useCalendar();
const { currentWeek, integrations, posts, changeDate } = useCalendar();
const modal = useModals();
const fetch = useFetch();
const getDate = useMemo(() => {
const date =
dayjs().isoWeek(week.currentWeek).isoWeekday(day).format('YYYY-MM-DD') +
dayjs().isoWeek(currentWeek).isoWeekday(day).format('YYYY-MM-DD') +
'T' +
hour +
':00';
return dayjs(date);
}, [week.currentWeek]);
}, [currentWeek]);
const postList = useMemo(() => {
return posts.filter((post) => {
return dayjs(post.publishDate).local().isSame(getDate);
});
}, [posts]);
const isBeforeNow = useMemo(() => {
return getDate.isBefore(dayjs());
}, [getDate]);
const [{ canDrop }, drop] = useDrop(() => ({
accept: 'post',
drop: (item: any) => {
if (isBeforeNow) return;
fetch(`/posts/${item.id}/date`, {
method: 'PUT',
body: JSON.stringify({ date: getDate.utc().format('YYYY-MM-DDTHH:mm:ss') }),
});
changeDate(item.id, getDate);
},
collect: (monitor) => ({
canDrop: isBeforeNow ? false : !!monitor.canDrop() && !!monitor.isOver(),
}),
}));
const editPost = useCallback(
(id: string) => async () => {
const data = await (await fetch(`/posts/${id}`)).json();
modal.openModal({
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
children: (
<ExistingDataContextProvider value={data}>
<AddEditModal
integrations={integrations.filter(
(f) => f.id === data.integration
)}
date={getDate}
/>
</ExistingDataContextProvider>
),
size: '80%',
title: `Edit post for ${getDate.format('DD/MM/YYYY HH:mm')}`,
});
},
[]
);
const addModal = useCallback(() => {
modal.openModal({
closeOnClickOutside: false,
closeOnEscape: false,
withCloseButton: false,
children: (
<AddEditModal integrations={week.integrations} date={getDate} />
),
children: <AddEditModal integrations={integrations} date={getDate} />,
size: '80%',
title: `Adding posts for ${getDate.format('DD/MM/YYYY HH:mm')}`,
});
}, []);
const isBeforeNow = useMemo(() => {
return getDate.isBefore(dayjs());
}, [getDate]);
return (
<div className={clsx("h-[calc(216px/6)] text-[12px] hover:bg-white/20 pointer flex justify-center items-center", isBeforeNow && 'bg-white/10 pointer-events-none')}>
<div
onClick={addModal}
className="flex-1 h-full flex justify-center items-center"
>
{isBeforeNow ? '' : '+ Add'}
<div className="relative w-full h-full">
<div className="absolute left-0 top-0 w-full h-full">
<div
ref={drop}
className={clsx(
'h-[calc(216px/6)] text-[12px] pointer w-full overflow-hidden justify-center overflow-x-auto flex scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary',
isBeforeNow && 'bg-secondary',
canDrop && 'bg-white/80'
)}
>
{postList.map((post) => (
<div
key={post.id}
className={clsx(
postList.length > 1 && 'w-[33px] basis-[28px]',
'h-full text-white relative flex justify-center items-center flex-grow-0 flex-shrink-0'
)}
>
<div className="relative flex gap-[5px] items-center">
<CalendarItem
date={getDate}
editPost={editPost(post.id)}
post={post}
integrations={integrations}
/>
</div>
</div>
))}
{!isBeforeNow && (
<div
className={clsx(
!postList.length ? 'justify-center flex-1' : 'ml-[2px]',
'flex items-center cursor-pointer'
)}
>
<div
data-tooltip-id="tooltip"
data-tooltip-content={
'Schedule for ' + getDate.format('DD/MM/YYYY HH:mm')
}
onClick={addModal}
className={clsx(
'w-[20px] h-[20px] bg-forth rounded-full flex justify-center items-center hover:bg-seventh'
)}
>
+
</div>
</div>
)}
</div>
</div>
</div>
);
@ -91,51 +232,53 @@ const CalendarColumn: FC<{ day: number; hour: string }> = (props) => {
export const Calendar = () => {
return (
<div>
<div className="grid grid-cols-8 text-center border-tableBorder border-r">
{days.map((day) => (
<div
className="border-tableBorder border-l border-b h-[36px] border-t flex items-center justify-center bg-input text-[14px] sticky top-0"
key={day}
>
{day}
</div>
))}
{hours.map((hour) =>
days.map((day, index) => (
<>
{index === 0 ? (
<div
className="border-tableBorder border-l border-b h-[216px]"
key={day + hour}
>
{['00', '10', '20', '30', '40', '50'].map((num) => (
<div
key={day + hour + num}
className="h-[calc(216px/6)] text-[12px] flex justify-center items-center"
>
{hour.split(':')[0] + ':' + num}
</div>
))}
</div>
) : (
<div
className="border-tableBorder border-l border-b h-[216px] flex flex-col"
key={day + hour}
>
{['00', '10', '20', '30', '40', '50'].map((num) => (
<CalendarColumn
key={day + hour + num}
day={index}
hour={hour.split(':')[0] + ':' + num}
/>
))}
</div>
)}
</>
))
)}
<DNDProvider>
<div>
<div className="grid grid-cols-8 text-center border-tableBorder border-r">
{days.map((day) => (
<div
className="border-tableBorder border-l border-b h-[36px] border-t flex items-center justify-center bg-input text-[14px] sticky top-0 z-[100]"
key={day}
>
{day}
</div>
))}
{hours.map((hour) =>
days.map((day, index) => (
<>
{index === 0 ? (
<div
className="border-tableBorder border-l border-b h-[216px]"
key={day + hour}
>
{['00', '10', '20', '30', '40', '50'].map((num) => (
<div
key={day + hour + num}
className="h-[calc(216px/6)] text-[12px] flex justify-center items-center"
>
{hour.split(':')[0] + ':' + num}
</div>
))}
</div>
) : (
<div
className="border-tableBorder border-l border-b h-[216px] flex flex-col"
key={day + hour}
>
{['00', '10', '20', '30', '40', '50'].map((num) => (
<CalendarColumn
key={day + hour + num}
day={index}
hour={hour.split(':')[0] + ':' + num}
/>
))}
</div>
)}
</>
))
)}
</div>
</div>
</div>
</DNDProvider>
);
};

View File

@ -0,0 +1,13 @@
"use client";
import {FC, ReactNode} from "react";
import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
export const DNDProvider: FC<{children: ReactNode}> = ({children}) => {
return (
<DndProvider backend={HTML5Backend}>
{children}
</DndProvider>
)
}

View File

@ -0,0 +1,25 @@
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useCallback } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
export const useCustomProviderFunction = () => {
const { integration } = useIntegration();
const fetch = useFetch();
const get = useCallback(
async (funcName: string, customData?: string) => {
return (
await fetch('/integrations/function', {
method: 'POST',
body: JSON.stringify({
name: funcName,
id: integration?.id!,
data: customData,
}),
})
).json();
},
[integration]
);
return { get };
};

View File

@ -0,0 +1,20 @@
import {createContext, FC, ReactNode, useContext} from "react";
import {Post} from "@prisma/client";
const ExistingDataContext = createContext({
integration: '',
posts: [] as Post[],
settings: {} as any
});
export const ExistingDataContextProvider: FC<{children: ReactNode, value: any}> = ({children, value}) => {
return (
<ExistingDataContext.Provider value={value}>
{children}
</ExistingDataContext.Provider>
);
}
export const useExistingData = () => useContext(ExistingDataContext);

View File

@ -1,19 +1,19 @@
import removeMd from "remove-markdown";
import {useMemo} from "react";
export const useFormatting = (text: string[], params: {
export const useFormatting = (text: Array<{content: string, id?: string}>, params: {
removeMarkdown?: boolean,
saveBreaklines?: boolean,
specialFunc?: (text: string) => string,
}) => {
return useMemo(() => {
return text.map((value) => {
let newText = value;
let newText = value.content;
if (params.saveBreaklines) {
newText = newText.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢');
}
if (params.removeMarkdown) {
newText = removeMd(value);
newText = removeMd(value.content);
}
if (params.saveBreaklines) {
newText = newText.replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n');
@ -22,6 +22,7 @@ export const useFormatting = (text: string[], params: {
newText = params.specialFunc(newText);
}
return {
id: value.id,
text: newText,
count: params.removeMarkdown && params.saveBreaklines ? newText.replace(/\n/g, ' ').length : newText.length,
}

View File

@ -3,6 +3,6 @@
import {createContext, useContext} from "react";
import {Integrations} from "@gitroom/frontend/components/launches/calendar.context";
export const IntegrationContext = createContext<{integration: Integrations|undefined, value: string[]}>({integration: undefined, value: []});
export const IntegrationContext = createContext<{integration: Integrations|undefined, value: Array<{content: string, id?: string}>}>({integration: undefined, value: []});
export const useIntegration = () => useContext(IntegrationContext);

View File

@ -1,25 +1,24 @@
import {useEffect, useMemo} from 'react';
import { useForm } from 'react-hook-form';
import { UseFormProps } from 'react-hook-form/dist/types';
import {allProvidersSettings} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings";
import {useForm, useFormContext} from 'react-hook-form';
import {classValidatorResolver} from "@hookform/resolvers/class-validator";
const finalInformation = {} as {
[key: string]: { posts: string[]; settings: () => object; isValid: boolean };
[key: string]: { posts: Array<{id?: string, content: string, media?: Array<string>}>; settings: () => object; isValid: boolean };
};
export const useValues = (identifier: string, integration: string, value: string[]) => {
export const useValues = (initialValues: object, integration: string, identifier: string, value: Array<{id?: string, content: string, media?: Array<string>}>, dto: any) => {
const resolver = useMemo(() => {
const findValidator = allProvidersSettings.find((provider) => provider.identifier === identifier)!;
return classValidatorResolver(findValidator?.validator);
return classValidatorResolver(dto);
}, [integration]);
const form = useForm({
resolver
resolver,
values: initialValues,
mode: 'onChange'
});
const getValues = useMemo(() => {
return form.getValues;
}, [form]);
return () => ({...form.getValues(), __type: identifier});
}, [form, integration]);
finalInformation[integration]= finalInformation[integration] || {};
finalInformation[integration].posts = value;
@ -35,20 +34,7 @@ export const useValues = (identifier: string, integration: string, value: string
return form;
};
export const useSettings = (formProps?: Omit<UseFormProps, 'mode'>) => {
// const { integration } = useIntegration();
// const form = useForm({
// ...formProps,
// mode: 'onChange',
// });
//
// finalInformation[integration?.identifier!].settings = {
// __type: integration?.identifier!,
// ...form.getValues(),
// };
// return form;
};
export const useSettings = () => useFormContext();
export const getValues = () => finalInformation;
export const resetValues = () => {
Object.keys(finalInformation).forEach((key) => {

View File

@ -10,16 +10,18 @@ export const LaunchesComponent: FC<{
integrations: Integrations[]
}> = (props) => {
const { integrations } = props;
const sortedIntegrations = useMemo(() => {
return orderBy(integrations, ['type', 'identifier'], ['desc', 'asc']);
}, [integrations]);
return (
<CalendarWeekProvider integrations={sortedIntegrations}>
<div className="flex flex-1 flex-col">
<Filters />
<div className="flex flex-1 relative">
<div className="absolute w-full h-full flex flex-1 gap-[30px] overflow-hidden overflow-y-scroll scrollbar scrollbar-thumb-tableBorder scrollbar-track-secondary">
<div className="w-[330px] bg-third p-[16px] flex flex-col gap-[24px] sticky top-0">
<div className="w-[220px] bg-third p-[16px] flex flex-col gap-[24px] sticky top-0">
<h2 className="text-[20px]">Channels</h2>
<div className="gap-[16px] flex flex-col">
{sortedIntegrations.map((integration) => (

View File

@ -1,12 +0,0 @@
import {FC} from "react";
import {withProvider} from "@gitroom/frontend/components/launches/providers/high.order.provider";
const DevtoPreview: FC = () => {
return <div>asd</div>
};
const DevtoSettings: FC = () => {
return <div>asdfasd</div>
};
export default withProvider(DevtoSettings, DevtoPreview);

View File

@ -0,0 +1,31 @@
import {FC} from "react";
import {withProvider} from "@gitroom/frontend/components/launches/providers/high.order.provider";
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
import {useSettings} from "@gitroom/frontend/components/launches/helpers/use.values";
import {Input} from "@gitroom/react/form/input";
import {MediaComponent} from "@gitroom/frontend/components/media/media.component";
import {SelectOrganization} from "@gitroom/frontend/components/launches/providers/devto/select.organization";
import {DevtoTags} from "@gitroom/frontend/components/launches/providers/devto/devto.tags";
const DevtoPreview: FC = () => {
return <div>asd</div>
};
const DevtoSettings: FC = () => {
const form = useSettings();
return (
<>
<Input label="Title" {...form.register('title')} />
<Input label="Canonical Link" {...form.register('canonical')} />
<MediaComponent label="Cover picture" description="Add a cover picture" {...form.register('main_image')} />
<div className="mt-[20px]">
<SelectOrganization {...form.register('organization') } />
</div>
<div>
<DevtoTags label="Tags (Maximum 4)" {...form.register('tags')} />
</div>
</>
)
};
export default withProvider(DevtoSettings, DevtoPreview, DevToSettingsDto);

View File

@ -0,0 +1,61 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
import { ReactTags } from 'react-tag-autocomplete';
export const DevtoTags: FC<{
name: string;
label: string;
onChange: (event: { target: { value: any[]; name: string } }) => void;
}> = (props) => {
const { onChange, name, label } = props;
const customFunc = useCustomProviderFunction();
const [tags, setTags] = useState<any[]>([]);
const { getValues } = useSettings();
const [tagValue, setTagValue] = useState<any[]>([]);
const onDelete = useCallback(
(tagIndex: number) => {
const modify = tagValue.filter((_, i) => i !== tagIndex);
setTagValue(modify);
onChange({ target: { value: modify, name } });
},
[tagValue]
);
const onAddition = useCallback(
(newTag: any) => {
if (tagValue.length >= 4) {
return;
}
const modify = [...tagValue, newTag];
setTagValue(modify);
onChange({ target: { value: modify, name } });
},
[tagValue]
);
useEffect(() => {
customFunc.get('tags').then((data) => setTags(data));
const settings = getValues()[props.name];
if (settings) {
setTagValue(settings);
}
}, []);
if (!tags.length) {
return null;
}
return (
<div>
<div className="font-['Inter'] text-[14px] mb-[6px]">{label}</div>
<ReactTags
suggestions={tags}
selected={tagValue}
onAdd={onAddition}
onDelete={onDelete}
/>
</div>
);
};

View File

@ -0,0 +1,44 @@
import { FC, useEffect, useState } from 'react';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { Select } from '@gitroom/react/form/select';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
export const SelectOrganization: FC<{
name: string;
onChange: (event: { target: { value: string; name: string } }) => void;
}> = (props) => {
const { onChange, name } = props;
const customFunc = useCustomProviderFunction();
const [orgs, setOrgs] = useState([]);
const { getValues } = useSettings();
const [currentMedia, setCurrentMedia] = useState<string|undefined>();
const onChangeInner = (event: { target: { value: string, name: string } }) => {
setCurrentMedia(event.target.value);
onChange(event);
};
useEffect(() => {
customFunc.get('organizations').then((data) => setOrgs(data));
const settings = getValues()[props.name];
if (settings) {
setCurrentMedia(settings);
}
}, []);
if (!orgs.length) {
return null;
}
return (
<Select name={name} label="Select organization" onChange={onChangeInner} value={currentMedia}>
<option value="">--Select--</option>
{orgs.map((org: any) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</Select>
);
};

View File

@ -8,6 +8,11 @@ import { useHideTopEditor } from '@gitroom/frontend/components/launches/helpers/
import { useValues } from '@gitroom/frontend/components/launches/helpers/use.values';
import { FormProvider } from 'react-hook-form';
import { useMoveToIntegrationListener } from '@gitroom/frontend/components/launches/helpers/use.move.to.integration';
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import {
IntegrationContext,
useIntegration,
} from '@gitroom/frontend/components/launches/helpers/use.integration';
// This is a simple function that if we edit in place, we hide the editor on top
export const EditorWrapper: FC = (props) => {
@ -22,16 +27,28 @@ export const EditorWrapper: FC = (props) => {
return null;
};
export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
export const withProvider = (
SettingsComponent: FC,
PreviewComponent: FC,
dto?: any
) => {
return (props: {
identifier: string;
id: string;
value: string[];
value: Array<{ content: string; id?: string }>;
show: boolean;
}) => {
const [editInPlace, setEditInPlace] = useState(false);
const [InPlaceValue, setInPlaceValue] = useState(['']);
const [showTab, setShowTab] = useState(0);
const existingData = useExistingData();
const { integration } = useIntegration();
const [editInPlace, setEditInPlace] = useState(!!existingData.integration);
const [InPlaceValue, setInPlaceValue] = useState<
Array<{ id?: string; content: string }>
>(
existingData.integration
? existingData.posts.map((p) => ({ id: p.id, content: p.content }))
: [{ content: '' }]
);
const [showTab, setShowTab] = useState(existingData.integration ? 1 : 0);
// in case there is an error on submit, we change to the settings tab for the specific provider
useMoveToIntegrationListener(true, (identifier) => {
@ -42,16 +59,18 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
// this is a smart function, it updates the global value without updating the states (too heavy) and set the settings validation
const form = useValues(
props.identifier,
existingData.settings,
props.id,
editInPlace ? InPlaceValue : props.value
props.identifier,
editInPlace ? InPlaceValue : props.value,
dto
);
// change editor value
const changeValue = useCallback(
(index: number) => (newValue: string) => {
return setInPlaceValue((prev) => {
prev[index] = newValue;
prev[index].content = newValue;
return [...prev];
});
},
@ -62,7 +81,7 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
const addValue = useCallback(
(index: number) => () => {
setInPlaceValue((prev) => {
prev.splice(index + 1, 0, '');
prev.splice(index + 1, 0, { content: '' });
return [...prev];
});
},
@ -85,12 +104,15 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
setShowTab(editor ? 1 : 0);
if (editor && !editInPlace) {
setEditInPlace(true);
setInPlaceValue(props.value);
setInPlaceValue(
props.value.map((p) => ({ id: p.id, content: p.content }))
);
}
},
[props.value, editInPlace]
);
// this is a trick to prevent the data from being deleted, yet we don't render the elements
if (!props.show) {
return null;
}
@ -123,7 +145,7 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
<MDEditor
key={`edit_inner_${index}`}
height={InPlaceValue.length > 1 ? 200 : 500}
value={val}
value={val.content}
preview="edit"
// @ts-ignore
onChange={changeValue(index)}
@ -135,8 +157,21 @@ export const withProvider = (SettingsComponent: FC, PreviewComponent: FC) => {
))}
</div>
)}
{showTab === 2 && <SettingsComponent />}
{showTab === 0 && <PreviewComponent />}
{showTab === 2 && (
<div className="mt-[20px]">
<SettingsComponent />
</div>
)}
{showTab === 0 && (
<IntegrationContext.Provider
value={{
value: editInPlace ? InPlaceValue : props.value,
integration,
}}
>
<PreviewComponent />
</IntegrationContext.Provider>
)}
</div>
</FormProvider>
);

View File

@ -1,42 +1,21 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import localFont from 'next/font/local';
import clsx from 'clsx';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
const chirp = localFont({
src: [
{
path: './fonts/x/Chirp-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/x/Chirp-Bold.woff2',
weight: '700',
style: 'normal',
},
],
});
const LinkedinPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const newValues = useFormatting(topValue, {
removeMarkdown: true,
saveBreaklines: true,
specialFunc: (text: string) => {
return text.slice(0, 280);
}
return text.slice(0, 280);
},
});
return (
<div
className={clsx(
'max-w-[598px] px-[16px] border border-[#2E3336]',
chirp.className
)}
>
<div className={clsx('max-w-[598px] px-[16px] border border-[#2E3336]')}>
<div className="w-full h-full relative flex flex-col pt-[12px]">
{newValues.map((value, index) => (
<div
@ -77,7 +56,7 @@ const LinkedinPreview: FC = (props) => {
@username
</div>
</div>
<pre className={chirp.className}>{value.text}</pre>
<pre>{value.text}</pre>
</div>
</div>
))}

View File

@ -1,25 +1,9 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
import localFont from 'next/font/local';
import clsx from 'clsx';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting';
const chirp = localFont({
src: [
{
path: './fonts/x/Chirp-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/x/Chirp-Bold.woff2',
weight: '700',
style: 'normal',
},
],
});
const RedditPreview: FC = (props) => {
const { value: topValue, integration } = useIntegration();
const newValues = useFormatting(topValue, {
@ -34,7 +18,6 @@ const RedditPreview: FC = (props) => {
<div
className={clsx(
'max-w-[598px] px-[16px] border border-[#2E3336]',
chirp.className
)}
>
<div className="w-full h-full relative flex flex-col pt-[12px]">
@ -77,7 +60,7 @@ const RedditPreview: FC = (props) => {
@username
</div>
</div>
<pre className={chirp.className}>{value.text}</pre>
<pre>{value.text}</pre>
</div>
</div>
))}

View File

@ -1,9 +1,9 @@
import {FC} from "react";
import {Integrations} from "@gitroom/frontend/components/launches/calendar.context";
import DevtoProvider from "@gitroom/frontend/components/launches/providers/devto.provider";
import XProvider from "@gitroom/frontend/components/launches/providers/x.provider";
import LinkedinProvider from "@gitroom/frontend/components/launches/providers/linkedin.provider";
import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit.provider";
import DevtoProvider from "@gitroom/frontend/components/launches/providers/devto/devto.provider";
import XProvider from "@gitroom/frontend/components/launches/providers/x/x.provider";
import LinkedinProvider from "@gitroom/frontend/components/launches/providers/linkedin/linkedin.provider";
import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit/reddit.provider";
const Providers = [
{identifier: 'devto', component: DevtoProvider},
@ -12,7 +12,7 @@ const Providers = [
{identifier: 'reddit', component: RedditProvider},
];
export const ShowAllProviders: FC<{integrations: Integrations[], value: string[], selectedProvider?: Integrations}> = (props) => {
export const ShowAllProviders: FC<{integrations: Integrations[], value: Array<{content: string, id?: string}>, selectedProvider?: Integrations}> = (props) => {
const {integrations, value, selectedProvider} = props;
return (
<>

View File

@ -23,6 +23,7 @@ function LayoutContextInner(params: {children: ReactNode}) {
baseUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}
afterRequest={afterRequest}
>
{params?.children || <></>}
</FetchWrapperComponent>
)

View File

@ -5,12 +5,14 @@ import {ContextWrapper} from "@gitroom/frontend/components/layout/user.context";
import {NotificationComponent} from "@gitroom/frontend/components/notifications/notification.component";
import {TopMenu} from "@gitroom/frontend/components/layout/top.menu";
import {MantineWrapper} from "@gitroom/react/helpers/mantine.wrapper";
import {ToolTip} from "@gitroom/frontend/components/layout/top.tip";
export const LayoutSettings = ({children}: {children: ReactNode}) => {
const user = JSON.parse(headers().get('user')!);
return (
<ContextWrapper user={user}>
<MantineWrapper>
<ToolTip />
<div className="min-h-[100vh] w-full max-w-[1440px] mx-auto bg-primary px-[12px] text-white flex flex-col">
<div className="px-[23px] flex h-[80px] items-center justify-between z-[200] sticky top-0 bg-primary">
<div className="text-2xl">

View File

@ -0,0 +1,7 @@
'use client';
import { Tooltip } from 'react-tooltip';
export const ToolTip = () => {
return <Tooltip className="z-[200]" id="tooltip" />;
};

View File

@ -0,0 +1,200 @@
'use client';
import { ChangeEvent, FC, useCallback, useEffect, useState } from 'react';
import { Button } from '@gitroom/react/form/button';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Media } from '@prisma/client';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import {useFormState} from "react-hook-form";
import {useSettings} from "@gitroom/frontend/components/launches/helpers/use.values";
export const MediaBox: FC<{
setMedia: (params: { id: string; path: string }) => void;
closeModal: () => void;
}> = (props) => {
const { setMedia, closeModal } = props;
const [pages, setPages] = useState(0);
const [mediaList, setListMedia] = useState<Media[]>([]);
const fetch = useFetch();
const mediaDirectory = useMediaDirectory();
const loadMedia = useCallback(async () => {
return (await fetch('/media')).json();
}, []);
const uploadMedia = useCallback(
async (file: ChangeEvent<HTMLInputElement>) => {
const maxFileSize = 10 * 1024 * 1024;
if (
!file?.target?.files?.length ||
file?.target?.files?.[0]?.size > maxFileSize
)
return;
const formData = new FormData();
formData.append('file', file?.target?.files?.[0]);
const data = await (
await fetch('/media', {
method: 'POST',
body: formData,
})
).json();
console.log(data);
setListMedia([...mediaList, data]);
},
[mediaList]
);
const setNewMedia = useCallback(
(media: Media) => () => {
setMedia(media);
closeModal();
},
[]
);
const { data } = useSWR('get-media', loadMedia);
useEffect(() => {
if (data?.pages) {
setPages(data.pages);
}
if (data?.results && data?.results?.length) {
setListMedia([...data.results]);
}
}, [data]);
return (
<div className="fixed left-0 top-0 bg-black/80 z-[300] w-full h-full p-[60px] animate-fade">
<div className="w-full h-full bg-black border-tableBorder border-2 rounded-xl p-[20px] relative">
<button
onClick={closeModal}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-black 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>
<button
className="flex absolute right-[40px] top-[7px] pointer hover:bg-third rounded-lg transition-all group px-2.5 py-2.5 text-sm font-semibold bg-transparent text-gray-800 hover:bg-gray-100 focus:text-primary-500"
type="button"
>
<div className="relative flex gap-2 items-center justify-center">
<input
type="file"
className="absolute left-0 top-0 w-full h-full opacity-0"
accept="image/*"
onChange={uploadMedia}
/>
<span className="sc-dhKdcB fhJPPc w-4 h-4">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.5276 1.00176C7.3957 0.979897 8.25623 1.16248 9.04309 1.53435C9.82982 1.90617 10.5209 2.45677 11.065 3.14199C11.3604 3.51404 11.6084 3.92054 11.8045 4.3516C12.2831 4.21796 12.7853 4.17281 13.2872 4.22273C14.2108 4.3146 15.0731 4.72233 15.7374 5.3744C16.4012 6.02599 16.8292 6.88362 16.9586 7.808C17.088 8.73224 16.9124 9.67586 16.457 10.4887C16.1871 10.9706 15.5777 11.1424 15.0958 10.8724C14.614 10.6025 14.4422 9.99308 14.7122 9.51126C14.9525 9.08224 15.0471 8.57971 14.9779 8.08532C14.9087 7.59107 14.6807 7.13971 14.3364 6.8017C13.9925 6.46418 13.5528 6.25903 13.0892 6.21291C12.6258 6.16682 12.1584 6.28157 11.7613 6.5429C11.4874 6.7232 11.1424 6.7577 10.8382 6.63524C10.534 6.51278 10.3091 6.24893 10.2365 5.92912C10.1075 5.36148 9.8545 4.83374 9.49872 4.38568C9.14303 3.93773 8.69439 3.58166 8.18851 3.34258C7.68275 3.10355 7.13199 2.98717 6.57794 3.00112C6.02388 3.01507 5.47902 3.15905 4.98477 3.4235C4.49039 3.68801 4.05875 4.06664 3.72443 4.53247C3.39004 4.9984 3.16233 5.5387 3.06049 6.11239C2.95864 6.68613 2.98571 7.27626 3.1394 7.83712C3.29306 8.39792 3.56876 8.91296 3.94345 9.34361C4.30596 9.76027 4.26207 10.3919 3.84542 10.7544C3.42876 11.1169 2.79712 11.073 2.4346 10.6564C1.8607 9.99678 1.44268 9.213 1.2105 8.36566C0.978333 7.51837 0.937639 6.62828 1.09128 5.76282C1.24492 4.89732 1.58919 4.07751 2.09958 3.36634C2.61005 2.65507 3.27363 2.07075 4.04125 1.66005C4.80899 1.24927 5.65951 1.02361 6.5276 1.00176Z"
fill="currentColor"
></path>
<path
d="M8 12.4142L8 17C8 17.5523 8.44771 18 9 18C9.55228 18 10 17.5523 10 17V12.4142L11.2929 13.7071C11.6834 14.0976 12.3166 14.0976 12.7071 13.7071C13.0976 13.3166 13.0976 12.6834 12.7071 12.2929L9.70711 9.29289C9.61123 9.19702 9.50073 9.12468 9.38278 9.07588C9.26488 9.02699 9.13559 9 9 9C8.86441 9 8.73512 9.02699 8.61722 9.07588C8.50195 9.12357 8.3938 9.19374 8.29945 9.2864C8.29705 9.28875 8.29467 9.29111 8.2923 9.29349L5.29289 12.2929C4.90237 12.6834 4.90237 13.3166 5.29289 13.7071C5.68342 14.0976 6.31658 14.0976 6.70711 13.7071L8 12.4142Z"
fill="currentColor"
></path>
</svg>
</span>
<span>Upload assets</span>
</div>
</button>
<div className="flex flex-wrap gap-[10px] mt-[35px] pt-[20px] border-tableBorder border-t-2">
{mediaList.map((media) => (
<div
key={media.id}
className="w-[200px] h-[200px] border-tableBorder border-2 cursor-pointer"
onClick={setNewMedia(media)}
>
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
/>
</div>
))}
</div>
</div>
</div>
);
};
export const MediaComponent: FC<{
label: string;
description: string;
value?: { path: string; id: string };
name: string;
onChange: (event: {
target: { name: string; value?: { id: string; path: string } };
}) => void;
}> = (props) => {
const { name, label, description, onChange, value } = props;
const {getValues} = useSettings();
useEffect(() => {
const settings = getValues()[props.name];
if (settings) {
setCurrentMedia(settings);
}
}, []);
const [modal, setShowModal] = useState(false);
const [currentMedia, setCurrentMedia] = useState(value);
const mediaDirectory = useMediaDirectory();
const changeMedia = useCallback((m: { path: string; id: string }) => {
setCurrentMedia(m);
onChange({ target: { name, value: m } });
}, []);
const showModal = useCallback(() => {
setShowModal(!modal);
}, [modal]);
const clearMedia = useCallback(() => {
setCurrentMedia(undefined);
onChange({ target: { name, value: undefined } });
}, [value]);
return (
<div className="flex flex-col gap-[8px]">
{modal && <MediaBox setMedia={changeMedia} closeModal={showModal} />}
<div className="text-[14px]">{label}</div>
<div className="text-[12px]">{description}</div>
{!!currentMedia && (
<div className="my-[20px] cursor-pointer w-[200px] h-[200px] border-2 border-tableBorder">
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(currentMedia.path)}
onClick={() => window.open(mediaDirectory.set(currentMedia.path))}
/>
</div>
)}
<div className="flex">
<Button onClick={showModal}>Select</Button>
<Button secondary={true} onClick={clearMedia}>
Clear
</Button>
</div>
</div>
);
};

View File

@ -34,11 +34,21 @@ export async function middleware(request: NextRequest) {
}
try {
const user = await (await fetchBackend('/user/self', {
const userResponse = await fetchBackend('/user/self', {
headers: {
auth: authCookie?.value!
}
})).json();
});
if (userResponse.status === 401) {
return NextResponse.redirect(new URL('/auth/logout', nextUrl.href));
}
if ([200, 201].indexOf(userResponse.status) === -1) {
return NextResponse.redirect(new URL('/err', nextUrl.href));
}
const user = await userResponse.json();
const next = NextResponse.next();
next.headers.set('user', JSON.stringify(user));

View File

@ -23,6 +23,7 @@ module.exports = {
forth: '#612AD5',
fifth: '#28344F',
sixth: '#0B101B',
seventh: '#7236f1',
gray: '#8C8C8C',
input: '#131B2C',
inputText: '#64748B',
@ -30,7 +31,18 @@ module.exports = {
},
gridTemplateColumns: {
'13': 'repeat(13, minmax(0, 1fr));'
}
},
animation: {
fade: 'fadeOut 0.5s ease-in-out',
},
// that is actual animation
keyframes: theme => ({
fadeOut: {
'0%': { opacity: 0, transform: 'translateY(30px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
},
})
},
},
plugins: [

View File

@ -1,28 +1,32 @@
export interface Params {
baseUrl: string,
beforeRequest?: (url: string, options: RequestInit) => Promise<RequestInit>,
afterRequest?: (url: string, options: RequestInit, response: Response) => Promise<void>
baseUrl: string;
beforeRequest?: (url: string, options: RequestInit) => Promise<RequestInit>;
afterRequest?: (
url: string,
options: RequestInit,
response: Response
) => Promise<void>;
}
export const customFetch = (params: Params, auth?: string) => {
return async function newFetch (url: string, options: RequestInit = {}) {
const newRequestObject = await params?.beforeRequest?.(url, options);
const fetchRequest = await fetch(params.baseUrl + url, {
credentials: 'include',
...(
newRequestObject || options
),
headers: {
...options?.headers,
...auth ? {auth} : {},
'Content-Type': 'application/json',
'Accept': 'application/json',
}
});
await params?.afterRequest?.(url, options, fetchRequest);
return fetchRequest;
}
}
return async function newFetch(url: string, options: RequestInit = {}) {
const newRequestObject = await params?.beforeRequest?.(url, options);
const fetchRequest = await fetch(params.baseUrl + url, {
credentials: 'include',
...(newRequestObject || options),
headers: {
...(auth ? { auth } : {}),
...(options.body instanceof FormData
? {}
: { 'Content-Type': 'application/json' }),
Accept: 'application/json',
...options?.headers,
},
});
await params?.afterRequest?.(url, options, fetchRequest);
return fetchRequest;
};
};
export const fetchBackend = customFetch({
baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!
baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!,
});

View File

@ -74,7 +74,7 @@ export class BullMqClient extends ClientProxy {
return () => void 0;
}
async delay(pattern: string, jobId: string, delay: number) {
delay(pattern: string, jobId: string, delay: number) {
const queue = this.getQueue(pattern);
return queue.getJob(jobId).then((job) => job?.changeDelay(delay));
}
@ -84,6 +84,11 @@ export class BullMqClient extends ClientProxy {
return queue.getJob(jobId).then((job) => job?.remove());
}
job(pattern: string, jobId: string) {
const queue = this.getQueue(pattern);
return queue.getJob(jobId);
}
protected async dispatchEvent(
packet: ReadPacket<IBullMqEvent<any>>,
): Promise<any> {

View File

@ -14,6 +14,8 @@ import {IntegrationRepository} from "@gitroom/nestjs-libraries/database/prisma/i
import {PostsService} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.service";
import {PostsRepository} from "@gitroom/nestjs-libraries/database/prisma/posts/posts.repository";
import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integration.manager";
import {MediaService} from "@gitroom/nestjs-libraries/database/prisma/media/media.service";
import {MediaRepository} from "@gitroom/nestjs-libraries/database/prisma/media/media.repository";
@Global()
@Module({
@ -35,6 +37,8 @@ import {IntegrationManager} from "@gitroom/nestjs-libraries/integrations/integra
IntegrationRepository,
PostsService,
PostsRepository,
MediaService,
MediaRepository,
IntegrationManager
],
get exports() {

View File

@ -24,6 +24,15 @@ export class IntegrationRepository {
})
}
getIntegrationById(org: string, id: string) {
return this._integration.model.integration.findFirst({
where: {
organizationId: org,
id
}
});
}
getIntegrationsList(org: string) {
return this._integration.model.integration.findMany({
where: {

View File

@ -14,4 +14,8 @@ export class IntegrationService {
getIntegrationsList(org: string) {
return this._integrationRepository.getIntegrationsList(org);
}
getIntegrationById(org: string, id: string) {
return this._integrationRepository.getIntegrationById(org, id);
}
}

View File

@ -0,0 +1,50 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class MediaRepository {
constructor(private _media: PrismaRepository<'media'>) {}
saveFile(org: string, fileName: string, filePath: string) {
return this._media.model.media.create({
data: {
organization: {
connect: {
id: org,
},
},
name: fileName,
path: filePath,
},
});
}
async getMedia(org: string, page: number) {
const pageNum = (page || 1) - 1;
const query = {
where: {
organization: {
id: org,
},
},
};
const pages =
pageNum === 0
? Math.ceil((await this._media.model.media.count(query)) / 10)
: 0;
const results = await this._media.model.media.findMany({
where: {
organization: {
id: org,
},
},
skip: pageNum * 10,
take: 10,
});
return {
pages,
results,
};
}
}

View File

@ -0,0 +1,17 @@
import {Injectable} from "@nestjs/common";
import {MediaRepository} from "@gitroom/nestjs-libraries/database/prisma/media/media.repository";
@Injectable()
export class MediaService {
constructor(
private _mediaRepository: MediaRepository
){}
saveFile(org: string, fileName: string, filePath: string) {
return this._mediaRepository.saveFile(org, fileName, filePath);
}
getMedia(org: string, page: number) {
return this._mediaRepository.getMedia(org, page);
}
}

View File

@ -1,17 +1,56 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Post as PostBody } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
import dayjs from 'dayjs';
import { Integration, Post } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import { v4 as uuidv4 } from 'uuid';
import {instanceToInstance, instanceToPlain} from "class-transformer";
import {validate} from "class-validator";
dayjs.extend(isoWeek);
@Injectable()
export class PostsRepository {
constructor(private _post: PrismaRepository<'post'>) {}
getPost(id: string, includeIntegration = false) {
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();
return this._post.model.post.findMany({
where: {
organizationId: orgId,
publishDate: {
gte: startDate,
lte: endDate,
},
parentPostId: null,
},
select: {
id: true,
content: true,
publishDate: true,
releaseURL: true,
state: true,
integration: {
select: {
id: true,
providerIdentifier: true,
},
},
},
});
}
getPost(id: string, includeIntegration = false, orgId?: string) {
return this._post.model.post.findUnique({
where: {
id,
...(orgId ? { organizationId: orgId } : {}),
},
include: {
...(includeIntegration ? { integration: true } : {}),
@ -33,35 +72,56 @@ export class PostsRepository {
});
}
async createPost(orgId: string, date: string, body: PostBody) {
async changeDate(orgId: string, id: string, date: string) {
return this._post.model.post.update({
where: {
organizationId: orgId,
id,
},
data: {
publishDate: dayjs(date).toDate(),
},
});
}
async createOrUpdatePost(orgId: string, date: string, body: PostBody) {
const posts: Post[] = [];
for (const value of body.value) {
posts.push(
await this._post.model.post.create({
data: {
publishDate: dayjs(date).toDate(),
integration: {
connect: {
id: body.integration.id,
organizationId: orgId,
},
},
...(posts.length
? {
parentPost: {
connect: {
id: posts[posts.length - 1]?.id,
},
},
}
: {}),
content: value,
organization: {
connect: {
id: orgId,
},
},
const updateData = {
publishDate: dayjs(date).toDate(),
integration: {
connect: {
id: body.integration.id,
organizationId: orgId,
},
},
...(posts.length
? {
parentPost: {
connect: {
id: posts[posts.length - 1]?.id,
},
},
}
: {}),
content: value.content,
settings: JSON.stringify(body.settings),
organization: {
connect: {
id: orgId,
},
},
};
posts.push(
await this._post.model.post.upsert({
where: {
id: value.id || uuidv4()
},
create: updateData,
update: updateData,
})
);
}

View File

@ -5,6 +5,7 @@ import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport/client
import dayjs from 'dayjs';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { Integration, Post } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
type PostWithConditionals = Post & {
integration?: Integration;
@ -21,17 +22,35 @@ export class PostsService {
async getPostsRecursively(
id: string,
includeIntegration = false
includeIntegration = false,
orgId?: string
): Promise<PostWithConditionals[]> {
const post = await this._postRepository.getPost(id, includeIntegration);
const post = await this._postRepository.getPost(
id,
includeIntegration,
orgId
);
return [
post!,
...(post?.childrenPost?.length
? await this.getPostsRecursively(post.childrenPost[0].id)
? await this.getPostsRecursively(post.childrenPost[0].id, false, orgId)
: []),
];
}
getPosts(orgId: string, query: GetPostsDto) {
return this._postRepository.getPosts(orgId, query);
}
async getPost(orgId: string, id: string) {
const posts = await this.getPostsRecursively(id, false, orgId);
return {
posts,
integration: posts[0].integrationId,
settings: JSON.parse(posts[0].settings || '{}'),
};
}
async post(id: string) {
const [firstPost, ...morePosts] = await this.getPostsRecursively(id, true);
if (!firstPost) {
@ -39,49 +58,71 @@ export class PostsService {
}
if (firstPost.integration?.type === 'article') {
return this.postArticle(firstPost.integration!, [firstPost, ...morePosts]);
return this.postArticle(firstPost.integration!, [
firstPost,
...morePosts,
]);
}
return this.postSocial(firstPost.integration!, [firstPost, ...morePosts]);
}
private async postSocial(integration: Integration, posts: Post[]) {
const getIntegration = this._integrationManager.getSocialIntegration(integration.providerIdentifier);
if (!getIntegration) {
return;
}
const getIntegration = this._integrationManager.getSocialIntegration(
integration.providerIdentifier
);
if (!getIntegration) {
return;
}
const publishedPosts = await getIntegration.post(integration.internalId, integration.token, posts.map(p => ({
const publishedPosts = await getIntegration.post(
integration.internalId,
integration.token,
posts.map((p) => ({
id: p.id,
message: p.content,
settings: JSON.parse(p.settings || '{}'),
})));
}))
);
for (const post of publishedPosts) {
await this._postRepository.updatePost(post.id, post.postId, post.releaseURL);
}
for (const post of publishedPosts) {
await this._postRepository.updatePost(
post.id,
post.postId,
post.releaseURL
);
}
}
private async postArticle(integration: Integration, posts: Post[]) {
const getIntegration = this._integrationManager.getArticlesIntegration(integration.providerIdentifier);
if (!getIntegration) {
return;
}
const {postId, releaseURL} = await getIntegration.post(integration.token, posts.map(p => p.content).join('\n\n'), JSON.parse(posts[0].settings || '{}'));
await this._postRepository.updatePost(posts[0].id, postId, releaseURL);
const getIntegration = this._integrationManager.getArticlesIntegration(
integration.providerIdentifier
);
if (!getIntegration) {
return;
}
const { postId, releaseURL } = await getIntegration.post(
integration.token,
posts.map((p) => p.content).join('\n\n'),
JSON.parse(posts[0].settings || '{}')
);
await this._postRepository.updatePost(posts[0].id, postId, releaseURL);
}
async createPost(orgId: string, body: CreatePostDto) {
for (const post of body.posts) {
const posts = await this._postRepository.createPost(
const posts = await this._postRepository.createOrUpdatePost(
orgId,
body.date,
post
);
await this._workerServiceProducer.delete('post', posts[0].id);
this._workerServiceProducer.emit('post', {
id: posts[0].id,
options: {
delay: 0 // dayjs(posts[0].publishDate).diff(dayjs(), 'millisecond'),
delay: dayjs(posts[0].publishDate).diff(dayjs(), 'millisecond'),
},
payload: {
id: posts[0].id,
@ -89,4 +130,18 @@ export class PostsService {
});
}
}
async changeDate(orgId: string, id: string, date: string) {
await this._workerServiceProducer.delete('post', id);
this._workerServiceProducer.emit('post', {
id: id,
options: {
delay: dayjs(date).diff(dayjs(), 'millisecond'),
},
payload: {
id: id,
},
});
return this._postRepository.changeDate(orgId, id, date);
}
}

View File

@ -101,7 +101,7 @@ model Star {
model Media {
id String @id @default(uuid())
name String
url String
path String
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
posts PostMedia[]

View File

@ -0,0 +1,13 @@
import {IsDefined, IsString} from "class-validator";
export class IntegrationFunctionDto {
@IsString()
@IsDefined()
name: string;
@IsString()
@IsDefined()
id: string;
data: any;
}

View File

@ -0,0 +1,11 @@
import {IsDefined, IsString} from "class-validator";
export class MediaDto {
@IsString()
@IsDefined()
id: string;
@IsString()
@IsDefined()
path: string;
}

View File

@ -1,12 +1,24 @@
import {ArrayMinSize, IsArray, IsDateString, IsDefined, IsString, ValidateNested} from "class-validator";
import {Type} from "class-transformer";
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
import {
ArrayMinSize, IsArray, IsDateString, IsDefined, IsOptional, IsString, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
export class EmptySettings {}
export class Integration {
@IsDefined()
@IsString()
id: string
id: string;
}
export class PostContent {
@IsDefined()
@IsString()
content: string;
@IsOptional()
@IsString()
id: string;
}
export class Post {
@ -18,19 +30,18 @@ export class Post {
@IsDefined()
@ArrayMinSize(1)
@IsArray()
@IsString({ each: true })
value: string[];
@Type(() => PostContent)
@ValidateNested({ each: true })
value: PostContent[];
@Type(() => EmptySettings, {
keepDiscriminatorProperty: true,
keepDiscriminatorProperty: false,
discriminator: {
property: '__type',
subTypes: [
{ value: DevToSettingsDto, name: 'devto' },
],
subTypes: [{ value: DevToSettingsDto, name: 'devto' }],
},
})
settings: DevToSettingsDto
settings: DevToSettingsDto;
}
export class CreatePostDto {
@ -41,7 +52,7 @@ export class CreatePostDto {
@IsDefined()
@Type(() => Post)
@IsArray()
@ValidateNested({each: true})
@ValidateNested({ each: true })
@ArrayMinSize(1)
posts: Post[]
posts: Post[];
}

View File

@ -0,0 +1,17 @@
import {Type} from "class-transformer";
import {IsNumber, Max, Min} from "class-validator";
import dayjs from "dayjs";
export class GetPostsDto {
@Type(() => Number)
@IsNumber()
@Max(52)
@Min(1)
week: number;
@Type(() => Number)
@IsNumber()
@Max(dayjs().add(10, 'year').year())
@Min(2022)
year: number;
}

View File

@ -1,7 +1,2 @@
import {DevToSettingsDto} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto";
export const allProvidersSettings = [{
identifier: 'devto',
validator: DevToSettingsDto
}];
export type AllProvidersSettings = DevToSettingsDto;

View File

@ -1,24 +1,32 @@
import {IsArray, IsDefined, IsOptional, IsString} from "class-validator";
import {ArrayMaxSize, IsArray, IsDefined, IsOptional, IsString, Matches, MinLength, ValidateNested} from "class-validator";
import {MediaDto} from "@gitroom/nestjs-libraries/dtos/media/media.dto";
import {Type} from "class-transformer";
import {DevToTagsSettings} from "@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.tags.settings";
export class DevToSettingsDto {
@IsString()
@MinLength(2)
@IsDefined()
title: string;
@IsString()
@IsOptional()
main_image?: number;
@ValidateNested()
@Type(() => MediaDto)
main_image?: MediaDto;
@IsOptional()
@IsString()
canonical: string;
@IsString({
each: true
@Matches(/^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, {
message: 'Invalid URL'
})
@IsArray()
tags: string[];
canonical?: string;
@IsString()
@IsOptional()
organization?: string;
@IsArray()
@ArrayMaxSize(4)
@IsOptional()
tags: DevToTagsSettings[];
}

View File

@ -0,0 +1,9 @@
import {IsNumber, IsString} from "class-validator";
export class DevToTagsSettings {
@IsNumber()
value: number;
@IsString()
label: string;
}

View File

@ -1,27 +1,99 @@
import {ArticleProvider} from "@gitroom/nestjs-libraries/integrations/article/article.integrations.interface";
import { ArticleProvider } from '@gitroom/nestjs-libraries/integrations/article/article.integrations.interface';
import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto';
export class DevToProvider implements ArticleProvider {
identifier = 'devto';
name = 'Dev.to';
async authenticate(token: string) {
const {name, id, profile_image} = await (await fetch('https://dev.to/api/users/me', {
identifier = 'devto';
name = 'Dev.to';
async authenticate(token: string) {
const { name, id, profile_image } = await (
await fetch('https://dev.to/api/users/me', {
headers: {
'api-key': token,
},
})
).json();
return {
id,
name,
token,
picture: profile_image,
};
}
async tags(token: string) {
const tags = await (
await fetch('https://dev.to/api/tags?per_page=1000&page=1', {
headers: {
'api-key': token,
},
})
).json();
return tags.map((p: any) => ({ value: p.id, label: p.name }));
}
async organizations(token: string) {
const orgs = await (
await fetch('https://dev.to/api/articles/me/all?per_page=1000', {
headers: {
'api-key': token,
},
})
).json();
const allOrgs: string[] = [
...new Set(
orgs
.flatMap((org: any) => org?.organization?.username)
.filter((f: string) => f)
),
] as string[];
const fullDetails = await Promise.all(
allOrgs.map(async (org: string) => {
return (
await fetch(`https://dev.to/api/organizations/${org}`, {
headers: {
'api-key': token
}
})).json();
'api-key': token,
},
})
).json();
})
);
return {
id,
name,
token,
picture: profile_image
}
}
return fullDetails.map((org: any) => ({
id: org.id,
name: org.name,
username: org.username,
}));
}
async post(token: string, content: string, settings: object) {
return {
postId: '123',
releaseURL: 'https://dev.to'
}
}
}
async post(token: string, content: string, settings: DevToSettingsDto) {
const { id, url } = await (
await fetch(`https://dev.to/api/articles`, {
method: 'POST',
body: JSON.stringify({
article: {
title: settings.title,
body_markdown: content,
published: false,
main_image: settings?.main_image?.path
? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}${settings?.main_image?.path}`
: undefined,
tags: settings?.tags?.map((t) => t.label),
organization_id: settings.organization,
},
}),
headers: {
'Content-Type': 'application/json',
'api-key': token,
},
})
).json();
return {
postId: String(id),
releaseURL: url,
};
}
}

View File

@ -54,7 +54,7 @@ export class LinkedinProvider implements SocialProvider {
}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/linkedin`
)}&state=${state}&scope=${encodeURIComponent(
'openid profile w_member_social r_liteprofile'
'openid profile w_member_social'
)}`;
return {
url,

View File

@ -82,6 +82,7 @@ export class XProvider implements SocialProvider {
accessToken: string,
postDetails: PostDetails[],
): Promise<PostResponse[]> {
console.log('hello');
const client = new TwitterApi(accessToken);
const {data: {username}} = await client.v2.me({
"user.fields": "username"

View File

@ -0,0 +1,39 @@
import { Global, Module } from '@nestjs/common';
import {MulterModule} from "@nestjs/platform-express";
import {diskStorage} from "multer";
import {mkdirSync} from 'fs';
import {extname} from 'path';
const storage = diskStorage({
destination: (req, file, cb) => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); // Month is zero-based, hence +1
const day = String(now.getDate()).padStart(2, '0');
const dir = `${process.env.UPLOAD_DIRECTORY}/${year}/${month}/${day}`;
// Create the directory if it doesn't exist
mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
// Generate a unique filename here if needed
const randomName = Array(32).fill(null).map(() => (Math.round(Math.random() * 16)).toString(16)).join('');
cb(null, `${randomName}${extname(file.originalname)}`);
},
});
@Global()
@Module({
imports: [
MulterModule.register({
storage
}),
],
get exports() {
return this.imports;
},
})
export class UploadModule {}

View File

@ -0,0 +1,22 @@
"use client";
import {DetailedHTMLProps, FC, SelectHTMLAttributes, useMemo} from "react";
import {clsx} from "clsx";
import {useFormContext} from "react-hook-form";
export const Select: FC<DetailedHTMLProps<SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement> & {label: string, name: string}> = (props) => {
const {label, className, ...rest} = props;
const form = useFormContext();
const err = useMemo(() => {
if (!form || !form.formState.errors[props?.name!]) return;
return form?.formState?.errors?.[props?.name!]?.message! as string;
}, [form?.formState?.errors?.[props?.name!]?.message]);
return (
<div className="flex flex-col gap-[6px]">
<div className="font-['Inter'] text-[14px]">{label}</div>
<select {...form.register(props.name)} className={clsx("bg-input h-[44px] px-[16px] outline-none border-fifth border rounded-[4px] text-inputText placeholder-inputText", className)} {...rest} />
<div className="text-red-400 text-[12px]">{err || <>&nbsp;</>}</div>
</div>
)
}

View File

@ -0,0 +1,11 @@
import {useCallback} from "react";
export const useMediaDirectory = () => {
const set = useCallback((path: string) => {
return `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}${path}`;
}, []);
return {
set,
}
}

185
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@nestjs/microservices": "^10.3.1",
"@nestjs/platform-express": "^10.0.2",
"@nestjs/schedule": "^4.0.0",
"@nestjs/serve-static": "^4.0.1",
"@novu/node": "^0.23.0",
"@novu/notification-center": "^0.23.0",
"@prisma/client": "^5.8.1",
@ -46,14 +47,19 @@
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"multer": "^1.4.5-lts.1",
"nestjs-command": "^3.1.4",
"next": "13.4.4",
"prisma-paginate": "^5.2.1",
"react": "18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.50.1",
"react-query": "^3.39.3",
"react-router-dom": "6.11.2",
"react-tag-autocomplete": "^7.2.0",
"react-tooltip": "^5.26.2",
"redis": "^4.6.12",
"reflect-metadata": "^0.1.13",
"remove-markdown": "^0.5.0",
@ -61,6 +67,7 @@
"simple-statistics": "^7.8.3",
"stripe": "^14.14.0",
"sweetalert2": "^11.10.5",
"swr": "^2.2.5",
"tslib": "^2.3.0",
"twitter-api-v2": "^1.16.0",
"yargs": "^17.7.2"
@ -86,6 +93,7 @@
"@testing-library/react": "14.0.0",
"@types/cookie-parser": "^1.4.6",
"@types/jest": "^29.4.0",
"@types/multer": "^1.4.11",
"@types/node": "18.16.9",
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",
@ -4048,6 +4056,23 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/platform-express/node_modules/multer": {
"version": "1.4.4-lts.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
"integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.0.tgz",
@ -4090,6 +4115,37 @@
"typescript": ">=4.8.2"
}
},
"node_modules/@nestjs/serve-static": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-4.0.1.tgz",
"integrity": "sha512-AoOrVdAe+WmsceuCcA8nWmKUYmaOsg9pqBCbIj7PS4W3XdikJQMtfxgSIoOlyUksZdhTBFjHqKh0Yhpj6pulwQ==",
"dependencies": {
"path-to-regexp": "0.2.5"
},
"peerDependencies": {
"@fastify/static": "^6.5.0",
"@nestjs/common": "^9.0.0 || ^10.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0",
"express": "^4.18.1",
"fastify": "^4.7.0"
},
"peerDependenciesMeta": {
"@fastify/static": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
}
}
},
"node_modules/@nestjs/serve-static/node_modules/path-to-regexp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz",
"integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q=="
},
"node_modules/@nestjs/testing": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.1.tgz",
@ -5428,6 +5484,21 @@
"react": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
@ -6869,6 +6940,15 @@
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="
},
"node_modules/@types/multer": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz",
"integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "18.16.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.9.tgz",
@ -9556,6 +9636,11 @@
"validator": "^13.9.0"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@ -11010,6 +11095,16 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true
},
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
@ -12537,8 +12632,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.2.7",
@ -17618,9 +17712,9 @@
}
},
"node_modules/multer": {
"version": "1.4.4-lts.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
"integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==",
"version": "1.4.5-lts.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
"integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
@ -19741,6 +19835,43 @@
"node": ">=0.10.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@ -19874,6 +20005,17 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-tag-autocomplete": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.2.0.tgz",
"integrity": "sha512-ahTJGOZJfwdQ0cWw7U27/KUqY40BEadAClp62vuHxuNMSCV3bGXcWas5PoTGISuBJyN3qRypTDrZSNJ5GY0Iug==",
"engines": {
"node": ">= 16.12.0"
},
"peerDependencies": {
"react": "^18.0.0"
}
},
"node_modules/react-textarea-autosize": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz",
@ -19890,6 +20032,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-tooltip": {
"version": "5.26.2",
"resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.26.2.tgz",
"integrity": "sha512-C1qHiqWYn6l5c98kL/NKFyJSw5G11vUVJkgOPcKgn306c5iL5317LxMNn5Qg1GSSM7Qvtsd6KA5MvwfgxFF7Dg==",
"dependencies": {
"@floating-ui/dom": "^1.6.1",
"classnames": "^2.3.0"
},
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -19972,6 +20127,14 @@
"node": ">=4"
}
},
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/reflect-metadata": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz",
@ -21908,6 +22071,18 @@
"url": "https://github.com/sponsors/limonte"
}
},
"node_modules/swr": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
"dependencies": {
"client-only": "^0.0.1",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

View File

@ -21,6 +21,7 @@
"@nestjs/microservices": "^10.3.1",
"@nestjs/platform-express": "^10.0.2",
"@nestjs/schedule": "^4.0.0",
"@nestjs/serve-static": "^4.0.1",
"@novu/node": "^0.23.0",
"@novu/notification-center": "^0.23.0",
"@prisma/client": "^5.8.1",
@ -46,14 +47,19 @@
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"multer": "^1.4.5-lts.1",
"nestjs-command": "^3.1.4",
"next": "13.4.4",
"prisma-paginate": "^5.2.1",
"react": "18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.50.1",
"react-query": "^3.39.3",
"react-router-dom": "6.11.2",
"react-tag-autocomplete": "^7.2.0",
"react-tooltip": "^5.26.2",
"redis": "^4.6.12",
"reflect-metadata": "^0.1.13",
"remove-markdown": "^0.5.0",
@ -61,6 +67,7 @@
"simple-statistics": "^7.8.3",
"stripe": "^14.14.0",
"sweetalert2": "^11.10.5",
"swr": "^2.2.5",
"tslib": "^2.3.0",
"twitter-api-v2": "^1.16.0",
"yargs": "^17.7.2"
@ -86,6 +93,7 @@
"@testing-library/react": "14.0.0",
"@types/cookie-parser": "^1.4.6",
"@types/jest": "^29.4.0",
"@types/multer": "^1.4.11",
"@types/node": "18.16.9",
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",