From fc11d8c14aa1dcc0e3807db3275604489b4176d1 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 6 Feb 2025 21:16:03 +0700 Subject: [PATCH] feat: webhook --- apps/backend/src/api/api.module.ts | 2 + .../src/api/routes/webhooks.controller.ts | 50 ++++ .../auth/permissions/permissions.service.ts | 13 +- .../permissions/subscription.exception.ts | 39 ++- .../frontend/src/app/(site)/settings/page.tsx | 17 +- .../src/components/launches/menu/menu.tsx | 1 - .../src/components/layout/impersonate.tsx | 2 +- .../components/layout/settings.component.tsx | 56 ++-- .../src/components/layout/top.menu.tsx | 13 + .../public-api/public.component.tsx | 2 +- .../components/settings/teams.component.tsx | 5 +- .../src/components/webhooks/webhooks.tsx | 245 ++++++++++++++++++ apps/workers/src/app/posts.controller.ts | 23 +- .../src/database/prisma/database.module.ts | 4 + .../database/prisma/posts/posts.repository.ts | 29 +++ .../database/prisma/posts/posts.service.ts | 9 +- .../src/database/prisma/schema.prisma | 29 +++ .../database/prisma/subscriptions/pricing.ts | 6 + .../prisma/webhooks/webhooks.repository.ts | 87 +++++++ .../prisma/webhooks/webhooks.service.ts | 98 +++++++ .../src/dtos/webhooks/webhooks.dto.ts | 44 ++++ 21 files changed, 706 insertions(+), 68 deletions(-) create mode 100644 apps/backend/src/api/routes/webhooks.controller.ts create mode 100644 apps/frontend/src/components/webhooks/webhooks.tsx create mode 100644 libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.repository.ts create mode 100644 libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.service.ts create mode 100644 libraries/nestjs-libraries/src/dtos/webhooks/webhooks.dto.ts diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index e7f9386e..4fb94fbf 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -28,6 +28,7 @@ import { RootController } from '@gitroom/backend/api/routes/root.controller'; import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments'; +import { WebhookController } from '@gitroom/backend/api/routes/webhooks.controller'; const authenticatedController = [ UsersController, @@ -42,6 +43,7 @@ const authenticatedController = [ MessagesController, CopilotController, AgenciesController, + WebhookController, ]; @Module({ imports: [ diff --git a/apps/backend/src/api/routes/webhooks.controller.ts b/apps/backend/src/api/routes/webhooks.controller.ts new file mode 100644 index 00000000..19a78730 --- /dev/null +++ b/apps/backend/src/api/routes/webhooks.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; +import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; +import { Organization } from '@prisma/client'; +import { ApiTags } from '@nestjs/swagger'; +import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service'; +import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; +import { + AuthorizationActions, + Sections, +} from '@gitroom/backend/services/auth/permissions/permissions.service'; +import { + UpdateDto, + WebhooksDto, +} from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto'; + +@ApiTags('Webhooks') +@Controller('/webhooks') +export class WebhookController { + constructor(private _webhooksService: WebhooksService) {} + + @Get('/') + async getStatistics(@GetOrgFromRequest() org: Organization) { + return this._webhooksService.getWebhooks(org.id); + } + + @Post('/') + @CheckPolicies([AuthorizationActions.Create, Sections.WEBHOOKS]) + async createAWebhook( + @GetOrgFromRequest() org: Organization, + @Body() body: WebhooksDto + ) { + return this._webhooksService.createWebhook(org.id, body); + } + + @Put('/') + async updateWebhook( + @GetOrgFromRequest() org: Organization, + @Body() body: UpdateDto + ) { + return this._webhooksService.createWebhook(org.id, body); + } + + @Delete('/:id') + async deleteWebhook( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string + ) { + return this._webhooksService.deleteWebhook(org.id, id); + } +} diff --git a/apps/backend/src/services/auth/permissions/permissions.service.ts b/apps/backend/src/services/auth/permissions/permissions.service.ts index 7538dc9e..8c4f87f3 100644 --- a/apps/backend/src/services/auth/permissions/permissions.service.ts +++ b/apps/backend/src/services/auth/permissions/permissions.service.ts @@ -5,6 +5,7 @@ import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/s import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import dayjs from 'dayjs'; +import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service'; export enum Sections { CHANNEL = 'channel', @@ -15,6 +16,7 @@ export enum Sections { AI = 'ai', IMPORT_FROM_CHANNELS = 'import_from_channels', ADMIN = 'admin', + WEBHOOKS = 'webhooks', } export enum AuthorizationActions { @@ -31,7 +33,8 @@ export class PermissionsService { constructor( private _subscriptionService: SubscriptionService, private _postsService: PostsService, - private _integrationService: IntegrationService + private _integrationService: IntegrationService, + private _webhooksService: WebhooksService, ) {} async getPackageOptions(orgId: string) { const subscription = @@ -93,6 +96,14 @@ export class PermissionsService { } } + if (section === Sections.WEBHOOKS) { + const totalWebhooks = await this._webhooksService.getTotal(orgId); + if (totalWebhooks < options.webhooks) { + can(AuthorizationActions.Create, section); + continue; + } + } + // check for posts per month if (section === Sections.POSTS_PER_MONTH) { const createdAt = diff --git a/apps/backend/src/services/auth/permissions/subscription.exception.ts b/apps/backend/src/services/auth/permissions/subscription.exception.ts index 09b6cc06..3088674b 100644 --- a/apps/backend/src/services/auth/permissions/subscription.exception.ts +++ b/apps/backend/src/services/auth/permissions/subscription.exception.ts @@ -1,11 +1,17 @@ -import {ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus} from "@nestjs/common"; -import {AuthorizationActions, Sections} from "@gitroom/backend/services/auth/permissions/permissions.service"; +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { + AuthorizationActions, + Sections, +} from '@gitroom/backend/services/auth/permissions/permissions.service'; export class SubscriptionException extends HttpException { - constructor(message: { - section: Sections, - action: AuthorizationActions - }) { + constructor(message: { section: Sections; action: AuthorizationActions }) { super(message, HttpStatus.PAYMENT_REQUIRED); } } @@ -16,19 +22,23 @@ export class SubscriptionExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const status = exception.getStatus(); - const error: {section: Sections, action: AuthorizationActions} = exception.getResponse() as any; + const error: { section: Sections; action: AuthorizationActions } = + exception.getResponse() as any; const message = getErrorMessage(error); response.status(status).json({ - statusCode: status, - message, - url: process.env.FRONTEND_URL + '/billing', + statusCode: status, + message, + url: process.env.FRONTEND_URL + '/billing', }); } } -const getErrorMessage = (error: {section: Sections, action: AuthorizationActions}) => { +const getErrorMessage = (error: { + section: Sections; + action: AuthorizationActions; +}) => { switch (error.section) { case Sections.POSTS_PER_MONTH: switch (error.action) { @@ -40,5 +50,10 @@ const getErrorMessage = (error: {section: Sections, action: AuthorizationActions default: return 'You have reached the maximum number of channels for your subscription. Please upgrade your subscription to add more channels.'; } + case Sections.WEBHOOKS: + switch (error.action) { + default: + return 'You have reached the maximum number of webhooks for your subscription. Please upgrade your subscription to add more webhooks.'; + } } -} +}; diff --git a/apps/frontend/src/app/(site)/settings/page.tsx b/apps/frontend/src/app/(site)/settings/page.tsx index ba9acb8e..84b16a2c 100644 --- a/apps/frontend/src/app/(site)/settings/page.tsx +++ b/apps/frontend/src/app/(site)/settings/page.tsx @@ -1,9 +1,7 @@ +import { SettingsPopup } from '@gitroom/frontend/components/layout/settings.component'; + export const dynamic = 'force-dynamic'; -import { SettingsComponent } from '@gitroom/frontend/components/settings/settings.component'; -import { internalFetch } from '@gitroom/helpers/utils/internal.fetch'; -import { redirect } from 'next/navigation'; -import { RedirectType } from 'next/dist/client/components/redirect'; import { Metadata } from 'next'; import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; @@ -16,14 +14,5 @@ export default async function Index({ }: { searchParams: { code: string }; }) { - if (searchParams.code) { - await internalFetch('/settings/github', { - method: 'POST', - body: JSON.stringify({ code: searchParams.code }), - }); - - return redirect('/settings', RedirectType.replace); - } - - return ; + return ; } diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index 2b287e5c..6398e10d 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -17,7 +17,6 @@ import { BotPicture } from '@gitroom/frontend/components/launches/bot.picture'; import { CustomerModal } from '@gitroom/frontend/components/launches/customer.modal'; import { Integration } from '@prisma/client'; import { SettingsModal } from '@gitroom/frontend/components/launches/settings.modal'; -import { string } from 'yup'; import { CustomVariables } from '@gitroom/frontend/components/launches/add.provider.component'; import { useRouter } from 'next/navigation'; diff --git a/apps/frontend/src/components/layout/impersonate.tsx b/apps/frontend/src/components/layout/impersonate.tsx index d6ae692b..2e94f20c 100644 --- a/apps/frontend/src/components/layout/impersonate.tsx +++ b/apps/frontend/src/components/layout/impersonate.tsx @@ -114,7 +114,7 @@ export const Impersonate = () => { }, [data]); return ( -
+
diff --git a/apps/frontend/src/components/layout/settings.component.tsx b/apps/frontend/src/components/layout/settings.component.tsx index 6eeddc5d..8a6edf98 100644 --- a/apps/frontend/src/components/layout/settings.component.tsx +++ b/apps/frontend/src/components/layout/settings.component.tsx @@ -19,9 +19,11 @@ import { LogoutComponent } from '@gitroom/frontend/components/layout/logout.comp import { useSearchParams } from 'next/navigation'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { PublicComponent } from '@gitroom/frontend/components/public-api/public.component'; +import Link from 'next/link'; +import { Webhooks } from '@gitroom/frontend/components/webhooks/webhooks'; export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { - const {isGeneral} = useVariables(); + const { isGeneral } = useVariables(); const { getRef } = props; const fetch = useFetch(); const toast = useToaster(); @@ -39,7 +41,7 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { }, []); const url = useSearchParams(); - const showLogout = !url.get('onboarding') || user?.tier?.current === "FREE"; + const showLogout = !url.get('onboarding') || user?.tier?.current === 'FREE'; const loadProfile = useCallback(async () => { const personal = await (await fetch('/user/personal')).json(); @@ -85,8 +87,8 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { )}
{/*{!getRef && (*/} @@ -196,7 +198,10 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { {/*
*/} {/*)}*/} {!!user?.tier?.team_members && isGeneral && } - {!!user?.tier?.public_api && isGeneral && showLogout && } + {!!user?.tier?.webhooks && } + {!!user?.tier?.public_api && isGeneral && showLogout && ( + + )} {showLogout && }
@@ -205,32 +210,21 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { }; export const SettingsComponent = () => { - const settings = useModals(); - const openModal = useCallback(() => { - settings.openModal({ - children: , - classNames: { - modal: 'bg-transparent text-textColor', - }, - withCloseButton: false, - size: '100%', - }); - }, []); - return ( - - - + + + + + ); }; diff --git a/apps/frontend/src/components/layout/top.menu.tsx b/apps/frontend/src/components/layout/top.menu.tsx index c84ba34e..f4bc2f37 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -55,6 +55,13 @@ export const useMenuItems = () => { role: ['ADMIN', 'SUPERADMIN'], requireBilling: true, }, + { + name: 'Settings', + icon: 'settings', + path: '/settings', + role: ['ADMIN', 'SUPERADMIN'], + hide: true, + }, { name: 'Affiliate', icon: 'affiliate', @@ -76,6 +83,9 @@ export const TopMenu: FC = () => {
    {menuItems .filter((f) => { + if (f.hide) { + return false; + } if (f.requireBilling && !billingEnabled) { return false; } @@ -97,6 +107,9 @@ export const TopMenu: FC = () => { 'flex gap-2 items-center box px-[6px] md:px-[24px] py-[8px]', menuItems .filter((f) => { + if (f.hide) { + return false; + } if (f.role) { return f.role.includes(user?.role!); } diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index 1f211c3a..93c7038c 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -22,7 +22,7 @@ export const PublicComponent = () => { return (
    -

    Public API

    +

    Public API

    Use Postiz API to integrate with your tools.
    diff --git a/apps/frontend/src/components/settings/teams.component.tsx b/apps/frontend/src/components/settings/teams.component.tsx index 5ff00158..b834a431 100644 --- a/apps/frontend/src/components/settings/teams.component.tsx +++ b/apps/frontend/src/components/settings/teams.component.tsx @@ -186,8 +186,7 @@ export const TeamsComponent = () => { return (
    -

    Team Members

    -

    Account Managers

    +

    Team Members

    Invite your assistant or team member to manage your account
    @@ -238,7 +237,7 @@ export const TeamsComponent = () => { ))}
    -
    diff --git a/apps/frontend/src/components/webhooks/webhooks.tsx b/apps/frontend/src/components/webhooks/webhooks.tsx new file mode 100644 index 00000000..6d964d99 --- /dev/null +++ b/apps/frontend/src/components/webhooks/webhooks.tsx @@ -0,0 +1,245 @@ +import React, { FC, Fragment, useCallback, useMemo, useState } from 'react'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import useSWR from 'swr'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { Button } from '@gitroom/react/form/button'; +import { useModals } from '@mantine/modals'; +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import { Input } from '@gitroom/react/form/input'; +import { FormProvider, useForm } from 'react-hook-form'; +import { array, object, string } from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Select } from '@gitroom/react/form/select'; +import { PickPlatforms } from '@gitroom/frontend/components/launches/helpers/pick.platform.component'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import clsx from 'clsx'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; + +export const Webhooks: FC = () => { + const fetch = useFetch(); + const user = useUser(); + const modal = useModals(); + const toaster = useToaster(); + + const list = useCallback(async () => { + return (await fetch('/webhooks')).json(); + }, []); + + const { data, mutate } = useSWR('webhooks', list); + + const addWebhook = useCallback( + (data?: any) => () => { + modal.openModal({ + title: '', + withCloseButton: false, + classNames: { + modal: 'bg-transparent text-textColor', + }, + children: , + }); + }, + [] + ); + + const deleteHook = useCallback( + (data: any) => async () => { + if (await deleteDialog(`Are you sure you want to delete ${data.name}?`)) { + await fetch(`/webhooks/${data.id}`, { method: 'DELETE' }); + mutate(); + toaster.show('Webhook deleted successfully', 'success'); + } + }, + [] + ); + + return ( +
    +

    + Webhooks ({data?.length || 0}/{user?.tier?.webhooks}) +

    +
    + Webhooks are a way to get notified when something happens in Postiz via + an HTTP request. +
    +
    +
    + {!!data?.length && ( +
    +
    Name
    +
    URL
    +
    Edit
    +
    Delete
    + {data?.map((p: any) => ( + +
    {p.name}
    +
    {p.url}
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + ))} +
    + )} +
    + +
    +
    +
    +
    + ); +}; + +const details = object().shape({ + name: string().required(), + url: string().url().required(), + integrations: array(), +}); + +const options = [ + { label: 'All integrations', value: 'all' }, + { label: 'Specific integrations', value: 'specific' }, +]; + +export const AddOrEditWebhook: FC<{ data?: any; reload: () => void }> = ( + props +) => { + const { data, reload } = props; + const fetch = useFetch(); + const [allIntegrations, setAllIntegrations] = useState( + (data?.integrations?.length || 0) > 0 ? options[1] : options[0] + ); + const modal = useModals(); + const toast = useToaster(); + const form = useForm({ + resolver: yupResolver(details), + values: { + name: data?.name || '', + url: data?.url || '', + integrations: data?.integrations?.map((p: any) => p.integration) || [], + }, + }); + + const integrations = form.watch('integrations'); + + const integration = useCallback(async () => { + return (await fetch('/integrations/list')).json(); + }, []); + + const changeIntegration = useCallback( + (e: React.ChangeEvent) => { + const findValue = options.find( + (option) => option.value === e.target.value + )!; + setAllIntegrations(findValue); + if (findValue.value === 'all') { + form.setValue('integrations', []); + } + }, + [] + ); + + const { data: dataList, isLoading } = useSWR('integrations', integration); + + const callBack = useCallback( + async (values: any) => { + await fetch('/webhooks', { + method: data?.id ? 'PUT' : 'POST', + body: JSON.stringify({ + ...(data?.id ? { id: data.id } : {}), + ...values, + }), + }); + + toast.show( + data?.id + ? 'Webhook updated successfully' + : 'Webhook added successfully', + 'success' + ); + + modal.closeAll(); + reload(); + }, + [data, integrations] + ); + + return ( + +
    +
    + + + +
    + + + + {allIntegrations.value === 'specific' && dataList && !isLoading && ( + form.setValue('integrations', e)} + singleSelect={false} + toolTip={true} + isMain={true} + /> + )} + +
    +
    +
    +
    + ); +}; diff --git a/apps/workers/src/app/posts.controller.ts b/apps/workers/src/app/posts.controller.ts index f644dc32..90ef07b0 100644 --- a/apps/workers/src/app/posts.controller.ts +++ b/apps/workers/src/app/posts.controller.ts @@ -1,10 +1,15 @@ import { Controller } from '@nestjs/common'; import { EventPattern, Transport } from '@nestjs/microservices'; import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service'; @Controller() export class PostsController { - constructor(private _postsService: PostsService) {} + constructor( + private _postsService: PostsService, + private _webhooksService: WebhooksService + ) {} + @EventPattern('post', Transport.REDIS) async post(data: { id: string }) { console.log('processing', data); @@ -17,7 +22,19 @@ export class PostsController { } @EventPattern('sendDigestEmail', Transport.REDIS) - async sendDigestEmail(data: { subject: string, org: string; since: string }) { - return this._postsService.sendDigestEmail(data.subject, data.org, data.since); + async sendDigestEmail(data: { subject: string; org: string; since: string }) { + return this._postsService.sendDigestEmail( + data.subject, + data.org, + data.since + ); + } + + @EventPattern('webhooks', Transport.REDIS) + async webhooks(data: { org: string; since: string }) { + return this._webhooksService.fireWebhooks( + data.org, + data.since + ); } } diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts index 9345983e..610eb7db 100644 --- a/libraries/nestjs-libraries/src/database/prisma/database.module.ts +++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts @@ -29,6 +29,8 @@ import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agenc import { AgenciesRepository } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.repository'; import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; +import { WebhooksRepository } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.repository'; +import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service'; @Global() @Module({ @@ -47,6 +49,8 @@ import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short. SubscriptionRepository, NotificationService, NotificationsRepository, + WebhooksRepository, + WebhooksService, IntegrationService, IntegrationRepository, PostsService, diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 3b0d25ed..825deb8e 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -515,4 +515,33 @@ export class PostsRepository { }, }); } + + async getPostsSince(orgId: string, since: string) { + return this._post.model.post.findMany({ + where: { + organizationId: orgId, + publishDate: { + gte: new Date(since), + }, + deletedAt: null, + parentPostId: null, + }, + select: { + id: true, + content: true, + publishDate: true, + releaseURL: true, + state: true, + integration: { + select: { + id: true, + name: true, + providerIdentifier: true, + picture: true, + type: true, + }, + }, + }, + }); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 89d7390f..a3041482 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -22,6 +22,7 @@ import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/ import utc from 'dayjs/plugin/utc'; import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; +import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service'; dayjs.extend(utc); type PostWithConditionals = Post & { @@ -40,7 +41,8 @@ export class PostsService { private _stripeService: StripeService, private _integrationService: IntegrationService, private _mediaService: MediaService, - private _shortLinkService: ShortLinkService + private _shortLinkService: ShortLinkService, + private _webhookService: WebhooksService ) {} async getStatistics(orgId: string, id: string) { @@ -372,6 +374,11 @@ export class PostsService { true ); + await this._webhookService.digestWebhooks( + integration.organizationId, + dayjs(newPosts[0].publishDate).format('YYYY-MM-DDTHH:mm:00') + ); + await this.checkPlugs( integration.organizationId, getIntegration.identifier, diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index ef4dbf0b..4cbd162d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -33,6 +33,7 @@ model Organization { credits Credits[] plugs Plugs[] customers Customer[] + webhooks Webhooks[] } model User { @@ -290,6 +291,7 @@ model Integration { exisingPlugData ExisingPlugData[] rootInternalId String? additionalSettings String? @default("[]") + webhooks IntegrationsWebhooks[] @@index([rootInternalId]) @@index([updatedAt]) @@ -501,6 +503,33 @@ model PopularPosts { updatedAt DateTime @updatedAt } +model IntegrationsWebhooks { + integrationId String + integration Integration @relation(fields: [integrationId], references: [id]) + webhookId String + webhook Webhooks @relation(fields: [webhookId], references: [id]) + + @@unique([integrationId, webhookId]) + @@id([integrationId, webhookId]) + @@index([integrationId]) + @@index([webhookId]) +} + +model Webhooks { + id String @id @default(uuid()) + name String + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + integrations IntegrationsWebhooks[] + url String + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) + @@index([deletedAt]) +} + enum OrderStatus { PENDING ACCEPTED diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts index 7fb692f5..e1752c88 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts @@ -12,6 +12,7 @@ export interface PricingInnerInterface { image_generator?: boolean; image_generation_count: number; public_api: boolean; + webhooks: number; } export interface PricingInterface { [key: string]: PricingInnerInterface; @@ -31,6 +32,7 @@ export const pricing: PricingInterface = { import_from_channels: false, image_generator: false, public_api: false, + webhooks: 0, }, STANDARD: { current: 'STANDARD', @@ -46,6 +48,7 @@ export const pricing: PricingInterface = { import_from_channels: true, image_generator: false, public_api: true, + webhooks: 2, }, TEAM: { current: 'TEAM', @@ -61,6 +64,7 @@ export const pricing: PricingInterface = { import_from_channels: true, image_generator: true, public_api: true, + webhooks: 10, }, PRO: { current: 'PRO', @@ -76,6 +80,7 @@ export const pricing: PricingInterface = { import_from_channels: true, image_generator: true, public_api: true, + webhooks: 30, }, ULTIMATE: { current: 'ULTIMATE', @@ -91,5 +96,6 @@ export const pricing: PricingInterface = { import_from_channels: true, image_generator: true, public_api: true, + webhooks: 10000, }, }; diff --git a/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.repository.ts b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.repository.ts new file mode 100644 index 00000000..cc1b025c --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.repository.ts @@ -0,0 +1,87 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { WebhooksDto } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class WebhooksRepository { + constructor(private _webhooks: PrismaRepository<'webhooks'>) {} + + getTotal(orgId: string) { + return this._webhooks.model.webhooks.count({ + where: { + organizationId: orgId, + deletedAt: null, + }, + }); + } + + getWebhooks(orgId: string) { + return this._webhooks.model.webhooks.findMany({ + where: { + organizationId: orgId, + deletedAt: null, + }, + include: { + integrations: { + select: { + integration: { + select: { + id: true, + picture: true, + name: true, + }, + }, + }, + }, + }, + }); + } + + deleteWebhook(orgId: string, id: string) { + return this._webhooks.model.webhooks.update({ + where: { + id, + organizationId: orgId, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + async createWebhook(orgId: string, body: WebhooksDto) { + const { id } = await this._webhooks.model.webhooks.upsert({ + where: { + id: body.id || uuidv4(), + organizationId: orgId, + }, + create: { + organizationId: orgId, + url: body.url, + name: body.name, + }, + update: { + url: body.url, + name: body.name, + }, + }); + + await this._webhooks.model.webhooks.update({ + where: { + id, + organizationId: orgId, + }, + data: { + integrations: { + deleteMany: {}, + create: body.integrations.map((integration) => ({ + integrationId: integration.id, + })), + }, + }, + }); + + return { id }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.service.ts b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.service.ts new file mode 100644 index 00000000..d23bed29 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { WebhooksRepository } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.repository'; +import { WebhooksDto } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto'; +import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; +import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client'; +import { PostsRepository } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.repository'; + +@Injectable() +export class WebhooksService { + constructor( + private _webhooksRepository: WebhooksRepository, + private _postsRepository: PostsRepository, + private _workerServiceProducer: BullMqClient + ) {} + + getTotal(orgId: string) { + return this._webhooksRepository.getTotal(orgId); + } + + getWebhooks(orgId: string) { + return this._webhooksRepository.getWebhooks(orgId); + } + + createWebhook(orgId: string, body: WebhooksDto) { + return this._webhooksRepository.createWebhook(orgId, body); + } + + deleteWebhook(orgId: string, id: string) { + return this._webhooksRepository.deleteWebhook(orgId, id); + } + + async digestWebhooks(orgId: string, since: string) { + const date = new Date().toISOString(); + await ioRedis.watch('webhook_' + orgId); + const value = await ioRedis.get('webhook_' + orgId); + if (value) { + return; + } + + await ioRedis + .multi() + .set('webhook_' + orgId, date) + .expire('webhook_' + orgId, 60) + .exec(); + + this._workerServiceProducer.emit('webhooks', { + id: 'digest_' + orgId, + options: { + delay: 60000, + }, + payload: { + org: orgId, + since, + }, + }); + } + + async fireWebhooks(orgId: string, since: string) { + const list = await this._postsRepository.getPostsSince(orgId, since); + const webhooks = await this._webhooksRepository.getWebhooks(orgId); + const sendList = []; + for (const webhook of webhooks) { + const toSend = []; + if (webhook.integrations.length === 0) { + toSend.push(...list); + } else { + toSend.push( + ...list.filter((post) => + webhook.integrations.some( + (i) => i.integration.id === post.integration.id + ) + ) + ); + } + + sendList.push({ + url: webhook.url, + data: toSend, + }); + } + + return Promise.all( + sendList.map(async (s) => { + try { + await fetch(s.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(s.data), + }); + } catch (e) { + /**empty**/ + } + }) + ); + } +} diff --git a/libraries/nestjs-libraries/src/dtos/webhooks/webhooks.dto.ts b/libraries/nestjs-libraries/src/dtos/webhooks/webhooks.dto.ts new file mode 100644 index 00000000..d05db43b --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/webhooks/webhooks.dto.ts @@ -0,0 +1,44 @@ +import { IsDefined, IsOptional, IsString, IsUrl } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class WebhooksIntegrationDto { + @IsString() + @IsDefined() + id: string; +} + +export class WebhooksDto { + id: string; + + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsUrl() + @IsDefined() + url: string; + + @Type(() => WebhooksIntegrationDto) + @IsDefined() + integrations: WebhooksIntegrationDto[]; +} + +export class UpdateDto { + @IsString() + @IsDefined() + id: string; + + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsUrl() + @IsDefined() + url: string; + + @Type(() => WebhooksIntegrationDto) + @IsDefined() + integrations: WebhooksIntegrationDto[]; +}