diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index e0fc10235a..6c91608bdc 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -34,6 +34,7 @@ import { SpaceModule } from './features/space/space.module'; import { TrashModule } from './features/trash/trash.module'; import { UndoRedoModule } from './features/undo-redo/open-api/undo-redo.module'; import { UserModule } from './features/user/user.module'; +import { WebhookModule } from './features/webhook/webhook.module'; import { GlobalModule } from './global/global.module'; import { InitBootstrapProvider } from './global/init-bootstrap.provider'; import { LoggerModule } from './logger/logger.module'; @@ -70,6 +71,7 @@ export const appModules = { PluginModule, DashboardModule, CommentOpenApiModule, + WebhookModule, OrganizationModule, AiModule, ], diff --git a/apps/nestjs-backend/src/configs/threshold.config.ts b/apps/nestjs-backend/src/configs/threshold.config.ts index ecb52dba1d..0a4b4d3bb4 100644 --- a/apps/nestjs-backend/src/configs/threshold.config.ts +++ b/apps/nestjs-backend/src/configs/threshold.config.ts @@ -25,6 +25,8 @@ export const thresholdConfig = registerAs('threshold', () => ({ maxOpenapiAttachmentUploadSize: Number( process.env.MAX_OPENAPI_ATTACHMENT_UPLOAD_SIZE ?? Infinity ), + // Maximum limit for creating webhooks, If the environment variable is not set, it defaults to 10. + maxCreateWebhookLimit: Number(process.env.MAX_CREATE_WEBHOOK_LIMIT ?? 10), })); export const ThresholdConfig = () => Inject(thresholdConfig.KEY); diff --git a/apps/nestjs-backend/src/event-emitter/listeners/webhook.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/webhook.listener.ts new file mode 100644 index 0000000000..7fe9f44a82 --- /dev/null +++ b/apps/nestjs-backend/src/event-emitter/listeners/webhook.listener.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { WebhookFactory } from '../../features/webhook/webhook.factory'; +import { CoreEvent } from '../events'; + +// type IViewEvent = ViewUpdateEvent; +// type IRecordEvent = RecordCreateEvent | RecordDeleteEvent | RecordUpdateEvent; +// type IListenerEvent = IViewEvent | IRecordEvent; + +@Injectable() +export class WebhookListener { + constructor(private readonly webhookFactory: WebhookFactory) {} + + @OnEvent('base.*', { async: true }) + @OnEvent('table.*', { async: true }) + async listener(event: CoreEvent): Promise { + // event.context + this.webhookFactory.run('', event); + } +} diff --git a/apps/nestjs-backend/src/features/webhook/webhook.controller.ts b/apps/nestjs-backend/src/features/webhook/webhook.controller.ts new file mode 100644 index 0000000000..20ec8511ba --- /dev/null +++ b/apps/nestjs-backend/src/features/webhook/webhook.controller.ts @@ -0,0 +1,71 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import type { + IWebhookListVo, + IWebhookRunHistoriesVo, + IWebhookRunHistoryVo, + IWebhookVo, +} from '@teable/openapi'; +import { + createWebhookRoSchema, + getWebhookRunHistoryListQuerySchema, + ICreateWebhookRo, + IGetWebhookRunHistoryListQuery, + IUpdateWebhookRo, + updateWebhookRoSchema, +} from '@teable/openapi'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { WebhookService } from './webhook.service'; + +@Controller('api/:spaceId/webhook') +export class WebhookController { + constructor(private readonly webhookService: WebhookService) {} + + @Get() + @Permissions('space|create') + async getWebhookList(@Param('spaceId') spaceId: string): Promise { + return await this.webhookService.getWebhookList(spaceId); + } + + @Get(':webhookId') + async getWebhookById(@Param('webhookId') webhookId: string): Promise { + return await this.webhookService.getWebhookById(webhookId); + } + + @Post() + async createWebhook( + @Body(new ZodValidationPipe(createWebhookRoSchema)) body: ICreateWebhookRo + ): Promise { + return await this.webhookService.createWebhook(body); + } + + @Delete(':webhookId') + async deleteWebhook(@Param('webhookId') webhookId: string) { + return await this.webhookService.deleteWebhook(webhookId); + } + + @Put(':webhookId') + async updateWebhook( + @Param('webhookId') webhookId: string, + @Body(new ZodValidationPipe(updateWebhookRoSchema)) body: IUpdateWebhookRo + ): Promise { + return await this.webhookService.updateWebhook(webhookId, body); + } + + @Get(':webhookId/run-history') + async getWebhookRunHistoryList( + @Param('webhookId') webhookId: string, + @Query(new ZodValidationPipe(getWebhookRunHistoryListQuerySchema)) + query: IGetWebhookRunHistoryListQuery + ): Promise { + return await this.webhookService.getWebhookRunHistoryList(webhookId, query); + } + + @Get('/run-history/:runHistoryId') + async getWebhookRunHistoryById( + @Param('runHistoryId') runHistoryId: string + ): Promise { + return await this.webhookService.getWebhookRunHistoryById(runHistoryId); + } +} diff --git a/apps/nestjs-backend/src/features/webhook/webhook.factory.ts b/apps/nestjs-backend/src/features/webhook/webhook.factory.ts new file mode 100644 index 0000000000..4e39c3084f --- /dev/null +++ b/apps/nestjs-backend/src/features/webhook/webhook.factory.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import crypto from 'crypto'; +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import type { IWebhookVo } from '@teable/openapi'; +import { ContentType } from '@teable/openapi'; +import { filter, from, mergeMap } from 'rxjs'; +import { match } from 'ts-pattern'; +import type { CoreEvent } from '../../event-emitter/events'; +import { WebhookService } from './webhook.service'; + +type IWebhook = IWebhookVo & { secret: string | null }; + +@Injectable() +export class WebhookFactory { + private logger = new Logger(WebhookFactory.name); + + constructor( + private readonly httpService: HttpService, + private readonly webHookService: WebhookService + ) {} + + async run(spaceId: string, event: CoreEvent) { + const webHookList = await this.webHookService.getWebhookListBySpaceId(spaceId); + + // 10s + // event.payload + + from(webHookList).pipe( + filter((webhookContext) => { + return true; + }), + mergeMap((value) => { + return this.sendHttpRequest(value, event); + }, 10) + ); + } + + private sendHttpRequest(webhookContext: IWebhook, event: CoreEvent) { + const body = match(webhookContext.contentType) + .with(ContentType.Json, () => { + return JSON.stringify(event.payload); + }) + .with(ContentType.FormUrlencoded, () => { + return ''; + }) + .exhaustive(); + + const headers: { [key: string]: string } = {}; + headers['User-Agent'] = 'teable/1.0.0'; + headers['Content-Type'] = webhookContext.contentType; + headers['X-Event'] = event.name; + headers['X-Hook-ID'] = ''; + + if (webhookContext.secret) { + headers['X-Signature-256'] = this.signature(webhookContext.secret, body); + } + + return this.httpService + .post(webhookContext.url, body, { + headers: headers, + }) + .pipe(); + } + + private signature(secret: string, body: unknown): string { + const signature = crypto + .createHmac('sha256', secret) + .update(JSON.stringify(body)) + .digest('hex'); + return `sha256=${signature}`; + } +} diff --git a/apps/nestjs-backend/src/features/webhook/webhook.module.ts b/apps/nestjs-backend/src/features/webhook/webhook.module.ts new file mode 100644 index 0000000000..7b726ab43b --- /dev/null +++ b/apps/nestjs-backend/src/features/webhook/webhook.module.ts @@ -0,0 +1,17 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { WebhookController } from './webhook.controller'; +import { WebhookFactory } from './webhook.factory'; +import { WebhookService } from './webhook.service'; + +@Module({ + imports: [ + HttpModule.register({ + timeout: 5000, + }), + ], + controllers: [WebhookController], + exports: [WebhookService], + providers: [WebhookService, WebhookFactory], +}) +export class WebhookModule {} diff --git a/apps/nestjs-backend/src/features/webhook/webhook.service.ts b/apps/nestjs-backend/src/features/webhook/webhook.service.ts new file mode 100644 index 0000000000..fdd52a6db3 --- /dev/null +++ b/apps/nestjs-backend/src/features/webhook/webhook.service.ts @@ -0,0 +1,302 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import type { IUnPromisify } from '@teable/core'; +import { generateWebHookId, generateWebHookRunHistoryId } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import type { + ContentType, + ICreateWebhookRo, + IGetWebhookRunHistoryListQuery, + IUpdateWebhookRo, + IWebhookListVo, + IWebhookRunHistoriesVo, + IWebhookRunHistoryVo, + IWebhookVo, +} from '@teable/openapi'; +import { WebhookRunStatus } from '@teable/openapi'; +import { ClsService } from 'nestjs-cls'; +import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; +import type { IClsStore } from '../../types/cls'; + +@Injectable() +export class WebhookService { + private logger = new Logger(WebhookService.name); + + constructor( + private readonly prismaService: PrismaService, + @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, + private readonly cls: ClsService + ) {} + + async getWebhookList(spaceId: string): Promise { + const rawWebHookList = await this.prismaService.webhook.findMany({ + select: { + id: true, + url: true, + contentType: true, + secret: true, + events: true, + isEnabled: true, + createdTime: true, + lastModifiedTime: true, + }, + where: { + spaceId, + }, + }); + + return rawWebHookList.map((item) => this.wrapRawWebhook(item)); + } + + async getWebhookById(webhookId: string): Promise { + const rawData = await this.prismaService.webhook.findUniqueOrThrow({ + select: { + id: true, + url: true, + contentType: true, + secret: true, + events: true, + isEnabled: true, + createdTime: true, + lastModifiedTime: true, + }, + where: { + id: webhookId, + }, + }); + + return this.wrapRawWebhook(rawData); + } + + async getWebhookListBySpaceId( + spaceId: string + ): Promise<(IWebhookVo & { secret: string | null })[]> { + const rawDataList = await this.prismaService.webhook.findMany({ + select: { + id: true, + url: true, + contentType: true, + secret: true, + events: true, + isEnabled: true, + createdTime: true, + lastModifiedTime: true, + }, + where: { + spaceId, + }, + }); + + return rawDataList.map((item) => { + return { + ...this.wrapRawWebhook(item), + secret: item.secret, + }; + }); + } + + async createWebhook(body: ICreateWebhookRo): Promise { + const { spaceId } = body; + await this.checkWebhookLimit(spaceId); + + const rawWebHook = await this.create(body); + return this.wrapRawWebhook(rawWebHook); + } + + async deleteWebhook(webhookId: string) { + await this.prismaService.webhook.delete({ + where: { + id: webhookId, + }, + }); + } + + async updateWebhook(webhookId: string, body: IUpdateWebhookRo): Promise { + const rawWebHook = await this.update(webhookId, body); + return this.wrapRawWebhook(rawWebHook); + } + + async getWebhookRunHistoryList( + webhookId: string, + query: IGetWebhookRunHistoryListQuery + ): Promise { + const { cursor } = query; + const limit = 10; + + const rawDataList = await this.prismaService.webhookRunHistory.findMany({ + where: { + webhookId, + }, + take: limit + 1, + cursor: cursor ? { id: cursor } : undefined, + orderBy: { + createdTime: 'desc', + }, + }); + + const runHistories = rawDataList.map((v) => { + return { + id: v.id, + webhookId: v.webhookId, + event: v.event, + status: v.status, + request: v.request && JSON.parse(v.request), + response: v.response && JSON.parse(v.response), + createdTime: v.createdTime.toISOString(), + finishedTime: v.finishedTime?.toISOString(), + }; + }); + + let nextCursor: typeof cursor | undefined = undefined; + if (rawDataList.length > limit) { + const nextItem = rawDataList.pop(); + nextCursor = nextItem!.id; + } + return { + runHistories, + nextCursor, + }; + } + + async getWebhookRunHistoryById(runHistoryId: string): Promise { + const rawData = await this.prismaService.webhookRunHistory.findUniqueOrThrow({ + where: { + id: runHistoryId, + }, + }); + + return { + id: rawData.id, + webhookId: rawData.webhookId, + event: rawData.event, + status: rawData.status, + request: rawData.request && JSON.parse(rawData.request), + response: rawData.response && JSON.parse(rawData.response), + createdTime: rawData.createdTime.toISOString(), + finishedTime: rawData.finishedTime?.toISOString(), + }; + } + + async startRunLog(webhookId: string, event: string, request: string) { + const userId = this.cls.get('user.id'); + const runHistoryId = generateWebHookRunHistoryId(); + + await this.prismaService.webhookRunHistory.create({ + data: { + id: runHistoryId, + webhookId, + event: '', + status: WebhookRunStatus.Running, + request: '', + response: '{}', + createdBy: userId, + createdTime: new Date().toISOString(), + }, + }); + } + + async endRunLog(runHistoryId: string, response: string, isError?: boolean, errorMsg?: string) { + await this.prismaService.webhookRunHistory.update({ + data: { + status: WebhookRunStatus.Finished, + response: '', + isError, + errorMsg, + finishedTime: new Date().toISOString(), + }, + where: { + id: runHistoryId, + }, + }); + } + + private wrapRawWebhook(model: IUnPromisify>) { + const { secret, contentType, ...other } = model; + + return { + ...other, + contentType: contentType as ContentType, + events: JSON.parse(other.events!), + hasSecret: !secret, + createdTime: other.createdTime?.toISOString(), + lastModifiedTime: other.lastModifiedTime?.toISOString(), + }; + } + + private async create(input: ICreateWebhookRo) { + const { spaceId, baseIds, url, contentType, secret, events, isEnabled } = input; + const userId = this.cls.get('user.id'); + const webhookId = generateWebHookId(); + + return this.prismaService.webhook.create({ + select: { + id: true, + url: true, + contentType: true, + secret: true, + events: true, + isEnabled: true, + createdTime: true, + lastModifiedTime: true, + }, + data: { + id: webhookId, + spaceId: spaceId, + baseIds: baseIds && JSON.stringify(baseIds), + url: url, + method: 'POST', + contentType: contentType.toString(), + secret: secret, + events: events && JSON.stringify(events), + isEnabled: isEnabled, + createdBy: userId, + }, + }); + } + + private async update(webhookId: string, input: IUpdateWebhookRo) { + const { spaceId, baseIds, url, contentType, secret, events, isEnabled } = input; + const userId = this.cls.get('user.id'); + + return this.prismaService.webhook.update({ + select: { + id: true, + url: true, + contentType: true, + secret: true, + events: true, + isEnabled: true, + createdTime: true, + lastModifiedTime: true, + }, + data: { + spaceId: spaceId, + baseIds: JSON.stringify(baseIds), + url: url, + method: 'POST', + contentType: contentType, + secret: secret, + events: JSON.stringify(events), + isEnabled: isEnabled, + createdBy: userId, + }, + where: { + id: webhookId, + }, + }); + } + + private async checkWebhookLimit(spaceId: string) { + const webhookCount = await this.prismaService.webhook.count({ + where: { + spaceId, + }, + }); + + const { maxCreateWebhookLimit } = this.thresholdConfig; + if (webhookCount >= maxCreateWebhookLimit) { + throw new BadRequestException( + `Exceed the maximum limit of creating webhooks, the limit is ${maxCreateWebhookLimit}` + ); + } + } +} diff --git a/apps/nextjs-app/src/backend/api/rest/table.ssr.ts b/apps/nextjs-app/src/backend/api/rest/table.ssr.ts index 9c9e3a45fb..ded9fcea0c 100644 --- a/apps/nextjs-app/src/backend/api/rest/table.ssr.ts +++ b/apps/nextjs-app/src/backend/api/rest/table.ssr.ts @@ -20,8 +20,10 @@ import type { IGroupPointsVo, ListSpaceCollaboratorRo, IPublicSettingVo, + IWebhookListVo, } from '@teable/openapi'; import { + GET_WEBHOOK_LIST, ACCEPT_INVITATION_LINK, GET_BASE, GET_BASE_ALL, @@ -193,4 +195,10 @@ export class SsrApi { }) .then(({ data }) => data); } + + async getWebhookList(spaceId: string) { + return this.axios + .get(urlBuilder(GET_WEBHOOK_LIST, { spaceId })) + .then(({ data }) => data); + } } diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/index.ts b/apps/nextjs-app/src/features/app/blocks/space-setting/index.ts index 76101c3f71..e0bb3cc69d 100644 --- a/apps/nextjs-app/src/features/app/blocks/space-setting/index.ts +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/index.ts @@ -1,2 +1,3 @@ export * from './general'; export * from './collaborator'; +export * from './webhooks'; diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/WebhooksPage.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/WebhooksPage.tsx new file mode 100644 index 0000000000..9539e6a527 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/WebhooksPage.tsx @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { getWebhookList } from '@teable/openapi'; +import { ReactQueryKeys, useIsHydrated } from '@teable/sdk'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import React, { type FC, Fragment } from 'react'; +import { SpaceSettingContainer } from '@/features/app/components/SpaceSettingContainer'; +import { webhookConfig } from '@/features/i18n/webhook.config'; +import { DataTable } from './data-table/DataTable'; + +export const WebhooksPage: FC = () => { + const router = useRouter(); + const isHydrated = useIsHydrated(); + const { spaceId } = router.query as { spaceId: string }; + const { t } = useTranslation(webhookConfig.i18nNamespaces); + + const { data: webhooks } = useQuery({ + queryKey: ReactQueryKeys.webhookList(spaceId as string), + queryFn: ({ queryKey }) => getWebhookList(queryKey[1]).then((res) => res.data), + }); + + return ( + + {isHydrated && !!webhooks && ( + + + + )} + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/data-table/DataTable.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/data-table/DataTable.tsx new file mode 100644 index 0000000000..4c4dd4a508 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/data-table/DataTable.tsx @@ -0,0 +1,148 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColumnFiltersState, SortingState, VisibilityState } from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { deleteWebhook, getWebhookList } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk'; +import { ConfirmDialog } from '@teable/ui-lib'; +import { Button, Table, TableBody, TableCell, TableRow } from '@teable/ui-lib/shadcn'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import * as React from 'react'; +import { useState } from 'react'; +import { SpaceWebhookModalTrigger } from '@/features/app/components/webhook/SpaceWebhookModalTrigger'; +import { webhookConfig } from '@/features/i18n/webhook.config'; +import { useDataColumns } from './useDataColumns'; + +export function DataTable() { + const queryClient = useQueryClient(); + const router = useRouter(); + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); + const [sorting, setSorting] = React.useState([]); + const { t } = useTranslation(webhookConfig.i18nNamespaces); + const { spaceId } = router.query as { spaceId: string }; + + const [deleteId, setDeleteId] = useState(); + const [notificationUrl, setNotificationUrl] = useState(); + + const { data: webhookList } = useQuery({ + queryKey: ReactQueryKeys.webhookList(spaceId), + queryFn: ({ queryKey }) => getWebhookList(queryKey[1]).then(({ data }) => data), + }); + + const { mutate: deleteWebhookMutate, isLoading: deleteLoading } = useMutation({ + mutationFn: deleteWebhook, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ReactQueryKeys.webhookList(spaceId) }); + // deleteId && + // (await queryClient.invalidateQueries({ + // queryKey: ReactQueryKeys.personAccessToken(deleteId), + // })); + setDeleteId(undefined); + }, + }); + + const onEdit = (id: string) => { + // TODO: Implement edit webhook + }; + const onDelete = (id: string, url: string) => { + setDeleteId(id); + setNotificationUrl(url); + }; + + const actionButton = (id: string, url: string) => { + return ( + <> + + + + ); + }; + + const columns = useDataColumns({ + actionChildren: actionButton, + }); + const table = useReactTable({ + data: webhookList ?? [], + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + columnResizeMode: 'onChange', + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+
+

{t('webhook:availableWebhooks')}

+ + + +
+ + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {t('noResult')} + + + )} + +
+ { + if (!val) { + setDeleteId(undefined); + } + }} + title={t('webhook:deleteConfirm.title')} + description={t('webhook:deleteConfirm.description', { notificationUrl })} + onCancel={() => setDeleteId(undefined)} + cancelText={t('common:actions.cancel')} + confirmText={t('common:actions.confirm')} + confirmLoading={deleteLoading} + onConfirm={() => { + deleteId && + deleteWebhookMutate({ + spaceId, + webhookId: deleteId, + }); + }} + /> +
+ ); +} diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/data-table/useDataColumns.tsx b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/data-table/useDataColumns.tsx new file mode 100644 index 0000000000..9aa385ce5f --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/data-table/useDataColumns.tsx @@ -0,0 +1,96 @@ +import type { ColumnDef } from '@tanstack/react-table'; + +import { AlertCircle, Check } from '@teable/icons'; +import type { IWebhookVo } from '@teable/openapi'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import React, { useMemo } from 'react'; +import type { WebhookEventPayload } from '@/features/tempfile'; + +interface Props { + actionChildren?: (id: string, url: string) => React.ReactNode; +} + +export function useDataColumns(props: Props) { + const { actionChildren } = props; + const { t } = useTranslation(['webhook']); + + return useMemo(() => { + const data: ColumnDef[] = [ + { + accessorKey: 'lastStatus', + header: 'lastStatus', + cell: ({ row }) => ( + + + +
+ {row.getValue('lastStatus') === 'warning' ? ( + + ) : ( + + )} +
+
+ +

+ {row.getValue('lastStatus') === 'warning' + ? 'Last run had a warning.' + : 'Last run was successful.'} +

+
+
+
+ ), + }, + { + accessorKey: 'url', + header: 'url', + cell: ({ row }) => { + return ( +
+ {row.getValue('url')} +
+ ); + }, + }, + { + accessorKey: 'events', + header: 'events', + cell: ({ row }) => { + const events = row.getValue('events'); + + const additionalEventsCount = events.length > 2 ? events.length - 2 : 0; + + const displayText = events + .slice(0, 2) + .map((event) => t(`${event}.title`)) + .join(', '); + + return ( +
+ + {displayText} + {additionalEventsCount > 0 && ` ${t('moreDesc', { len: additionalEventsCount })}`} + +
+ ); + }, + }, + ]; + + if (actionChildren) { + data.push({ + accessorKey: 'id', + header: 'id', + enableHiding: false, + cell: ({ row }) => { + const id = row.getValue('id'); + const url = row.getValue('url'); + return actionChildren(id, url); + }, + }); + } + return data; + }, [actionChildren, t]); +} diff --git a/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/index.ts b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/index.ts new file mode 100644 index 0000000000..933353f54d --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space-setting/webhooks/index.ts @@ -0,0 +1 @@ +export * from './WebhooksPage'; diff --git a/apps/nextjs-app/src/features/app/components/webhook/FormEventsField.tsx b/apps/nextjs-app/src/features/app/components/webhook/FormEventsField.tsx new file mode 100644 index 0000000000..0bf5125e78 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/webhook/FormEventsField.tsx @@ -0,0 +1,110 @@ +import { + Checkbox, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + RadioGroup, + RadioGroupItem, +} from '@teable/ui-lib'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import type { ControllerRenderProps, FieldValues } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; +import type { WebhookEventPayload } from '@/features/tempfile'; +import { defaultEvents, EventsByWebhook } from '@/features/tempfile'; + +export function FormEventsField() { + const form = useFormContext(); + const { t } = useTranslation(['webhook']); + const [customEventVisible, setCustomEventVisible] = useState(false); + + const handleRadioValueChange = ( + value: string, + field: ControllerRenderProps + ) => { + switch (value) { + case 'record_event': + field.onChange(defaultEvents); + setCustomEventVisible(false); + break; + case 'all_event': + field.onChange(EventsByWebhook); + setCustomEventVisible(false); + break; + case 'custom_event': + field.onChange([]); + setCustomEventVisible(true); + break; + } + }; + + const RadioItem = ({ value, label }: { value: string; label: string }) => ( + + + + + {label} + + ); + + const CheckboxOption = ({ + field, + item, + }: { + field: ControllerRenderProps; + item: WebhookEventPayload; + }) => ( + + + + field.onChange( + checked + ? [...field.value, item] + : field.value?.filter((value: WebhookEventPayload) => value !== item) + ) + } + /> + +
+ {t(`${item}.title`)} + {t(`${item}.description`)} +
+
+ ); + + return ( + ( + + Which events would you like to trigger this webhook? + + handleRadioValueChange(value, field)} + defaultValue="record_event" + className="flex flex-col space-y-1" + > + + + + {customEventVisible && ( + + {EventsByWebhook.map((item, index) => ( + + ))} + + )} + + + + + )} + /> + ); +} diff --git a/apps/nextjs-app/src/features/app/components/webhook/SpaceWebhookModal.tsx b/apps/nextjs-app/src/features/app/components/webhook/SpaceWebhookModal.tsx new file mode 100644 index 0000000000..a431f26fac --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/webhook/SpaceWebhookModal.tsx @@ -0,0 +1,147 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import type { ICreateWebhookRo } from '@teable/openapi'; +import { ContentType, createWebhook, createWebhookRoSchema } from '@teable/openapi'; +import { + Button, + Checkbox, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, +} from '@teable/ui-lib'; +import { useTranslation } from 'next-i18next'; +import { useForm } from 'react-hook-form'; +import { FormEventsField } from '@/features/app/components/webhook/FormEventsField'; +import { webhookConfig } from '@/features/i18n/webhook.config'; +import { defaultEvents } from '@/features/tempfile'; + +const defaultValues: Partial = { + contentType: ContentType.Json, + events: defaultEvents, + isEnabled: true, +}; + +interface ISpaceWebhookModal { + spaceId: string; +} + +export const SpaceWebhookModal: React.FC = (props) => { + const { spaceId } = props; + const { t } = useTranslation(webhookConfig.i18nNamespaces); + const form = useForm({ + resolver: zodResolver(createWebhookRoSchema), + defaultValues: { + ...defaultValues, + spaceId, + }, + mode: 'onBlur', + }); + + const { mutate: createWebhookMutate } = useMutation({ + mutationFn: createWebhook, + onSuccess: () => { + // todo close modal + }, + }); + + // const { mutate: updateAccessTokenMutate, isLoading: updateAccessTokenLoading } = useMutation({ + // mutationFn: (updateRo: UpdateAccessTokenRo) => updateWebhook(accessTokenId, updateRo), + // onSuccess: async (data) => { + // // onSubmit?.(data.data); + // }, + // }); + + const onSubmit = (data: ICreateWebhookRo) => { + createWebhookMutate(data); + }; + + return ( +
+
{t('webhook:form.webhookDesc')}
+ +
+
+ + ( + + {t('webhook:form.notificationUrl')} + + + + + + )} + /> + ( + + {t('webhook:form.contentType')} + + + + )} + /> + ( + + {t('webhook:form.secret')} + + + + + + )} + /> + + ( + + + + +
+ {t('webhook:form.active')} + {t('webhook:form.activeDesc')} +
+
+ )} + /> + + +
+ +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/webhook/SpaceWebhookModalTrigger.tsx b/apps/nextjs-app/src/features/app/components/webhook/SpaceWebhookModalTrigger.tsx new file mode 100644 index 0000000000..71d1828474 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/webhook/SpaceWebhookModalTrigger.tsx @@ -0,0 +1,27 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@teable/ui-lib'; +import { useTranslation } from 'next-i18next'; +import { webhookConfig } from '@/features/i18n/webhook.config'; +import { SpaceWebhookModal } from './SpaceWebhookModal'; + +interface ISpaceWebhookModalTrigger { + query: string; + spaceId: string; +} + +export const SpaceWebhookModalTrigger: React.FC< + React.PropsWithChildren +> = (props) => { + const { children, spaceId } = props; + const { t } = useTranslation(webhookConfig.i18nNamespaces); + return ( + + {children} + + + {t('webhook:form.addWebhook')} + + + + + ); +}; diff --git a/apps/nextjs-app/src/features/app/layouts/SpaceSettingLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SpaceSettingLayout.tsx index 11c7ce06ba..71dfe19233 100644 --- a/apps/nextjs-app/src/features/app/layouts/SpaceSettingLayout.tsx +++ b/apps/nextjs-app/src/features/app/layouts/SpaceSettingLayout.tsx @@ -1,5 +1,5 @@ import type { DehydratedState } from '@tanstack/react-query'; -import { Component, Home, Users } from '@teable/icons'; +import { Component, Home, Users, Webhook } from '@teable/icons'; import type { IGetSpaceVo } from '@teable/openapi'; import type { IUser } from '@teable/sdk'; import { NotificationProvider, ReactQueryKeys, SessionProvider } from '@teable/sdk'; @@ -54,6 +54,12 @@ export const SpaceSettingLayout: React.FC<{ route: `/space/[spaceId]/setting/collaborator`, pathTo: `/space/${spaceId}/setting/collaborator`, }, + { + Icon: Webhook, + label: t('space:spaceSetting.webhooks'), + route: `/space/[spaceId]/setting/webhook`, + pathTo: `/space/${spaceId}/setting/webhook`, + }, ]; }, [spaceId, t]); diff --git a/apps/nextjs-app/src/features/i18n/webhook.config.ts b/apps/nextjs-app/src/features/i18n/webhook.config.ts new file mode 100644 index 0000000000..660ceb2943 --- /dev/null +++ b/apps/nextjs-app/src/features/i18n/webhook.config.ts @@ -0,0 +1,9 @@ +import type { I18nActiveNamespaces } from '@/lib/i18n'; + +export interface IWebhookConfig { + i18nNamespaces: I18nActiveNamespaces<'common' | 'sdk' | 'space' | 'webhook'>; +} + +export const webhookConfig: IWebhookConfig = { + i18nNamespaces: ['common', 'sdk', 'space', 'webhook'], +}; diff --git a/apps/nextjs-app/src/features/tempfile.ts b/apps/nextjs-app/src/features/tempfile.ts new file mode 100644 index 0000000000..0d439a48ed --- /dev/null +++ b/apps/nextjs-app/src/features/tempfile.ts @@ -0,0 +1,52 @@ +// TODO Remove this file and move its content to @teable/core + +// import type { WebhookEventPayload } from '@teable/core'; +// import { Event, EventsByWebhook } from '@teable/core'; + +import { Events } from '../../../nestjs-backend/src/event-emitter/events'; // i know, i know... this is a bad practice, but i'm just trying to make it work + +export { Events }; + +export type WebhookEventPayload = Extract< + Events, + | Events.BASE_CREATE + | Events.BASE_DELETE + | Events.BASE_UPDATE + | Events.TABLE_CREATE + | Events.TABLE_DELETE + | Events.TABLE_UPDATE + | Events.TABLE_FIELD_CREATE + | Events.TABLE_FIELD_DELETE + | Events.TABLE_FIELD_UPDATE + | Events.TABLE_RECORD_CREATE + | Events.TABLE_RECORD_DELETE + | Events.TABLE_RECORD_UPDATE + | Events.TABLE_VIEW_CREATE + | Events.TABLE_VIEW_DELETE + | Events.TABLE_VIEW_UPDATE +>; + +export const EventsByWebhook: WebhookEventPayload[] = [ + Events.BASE_CREATE, + Events.BASE_DELETE, + Events.BASE_UPDATE, + + Events.TABLE_CREATE, + Events.TABLE_DELETE, + Events.TABLE_UPDATE, + Events.TABLE_FIELD_CREATE, + Events.TABLE_FIELD_DELETE, + Events.TABLE_FIELD_UPDATE, + Events.TABLE_RECORD_CREATE, + Events.TABLE_RECORD_DELETE, + Events.TABLE_RECORD_UPDATE, + Events.TABLE_VIEW_CREATE, + Events.TABLE_VIEW_DELETE, + Events.TABLE_VIEW_UPDATE, +]; + +export const defaultEvents: WebhookEventPayload[] = [ + Events.TABLE_RECORD_CREATE, + Events.TABLE_RECORD_DELETE, + Events.TABLE_RECORD_UPDATE, +]; diff --git a/apps/nextjs-app/src/pages/space/[spaceId]/setting/webhook.tsx b/apps/nextjs-app/src/pages/space/[spaceId]/setting/webhook.tsx new file mode 100644 index 0000000000..550ba60699 --- /dev/null +++ b/apps/nextjs-app/src/pages/space/[spaceId]/setting/webhook.tsx @@ -0,0 +1,56 @@ +import { QueryClient, dehydrate } from '@tanstack/react-query'; +import { Role } from '@teable/core'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import type { GetServerSideProps } from 'next'; +import type { ReactElement } from 'react'; +import { WebhooksPage } from '@/features/app/blocks/space-setting'; +import { SpaceSettingLayout } from '@/features/app/layouts/SpaceSettingLayout'; +import { webhookConfig } from '@/features/i18n/webhook.config'; +import ensureLogin from '@/lib/ensureLogin'; +import { getTranslationsProps } from '@/lib/i18n'; +import { spaceRoleChecker } from '@/lib/space-role-checker'; +import type { NextPageWithLayout } from '@/lib/type'; +import withAuthSSR from '@/lib/withAuthSSR'; +import withEnv from '@/lib/withEnv'; + +const Webhook: NextPageWithLayout = () => ; + +export const getServerSideProps: GetServerSideProps = withEnv( + ensureLogin( + withAuthSSR(async (context, ssrApi) => { + const { spaceId } = context.query; + const queryClient = new QueryClient(); + + await Promise.all([ + queryClient.fetchQuery({ + queryKey: ReactQueryKeys.space(spaceId as string), + queryFn: ({ queryKey }) => ssrApi.getSpaceById(queryKey[1]), + }), + + queryClient.fetchQuery({ + queryKey: ReactQueryKeys.webhookList(spaceId as string), + queryFn: ({ queryKey }) => ssrApi.getWebhookList(queryKey[1]), + }), + ]); + + spaceRoleChecker({ + queryClient, + spaceId: spaceId as string, + roles: [Role.Owner], + }); + + return { + props: { + dehydratedState: dehydrate(queryClient), + ...(await getTranslationsProps(context, webhookConfig.i18nNamespaces)), + }, + }; + }) + ) +); + +Webhook.getLayout = function getLayout(page: ReactElement, pageProps) { + return {page}; +}; + +export default Webhook; diff --git a/packages/common-i18n/src/I18nNamespaces.ts b/packages/common-i18n/src/I18nNamespaces.ts index 3ddd0f5f96..d010aa81c4 100644 --- a/packages/common-i18n/src/I18nNamespaces.ts +++ b/packages/common-i18n/src/I18nNamespaces.ts @@ -11,6 +11,7 @@ import type space from './locales/en/space.json'; import type system from './locales/en/system.json'; import type table from './locales/en/table.json'; import type token from './locales/en/token.json'; +import type webhook from './locales/en/webhook.json'; import type zod from './locales/en/zod.json'; export interface I18nNamespaces { @@ -28,4 +29,5 @@ export interface I18nNamespaces { developer: typeof developer; plugin: typeof plugin; dashboard: typeof dashboard; + webhook: typeof webhook; } diff --git a/packages/common-i18n/src/locales/en/space.json b/packages/common-i18n/src/locales/en/space.json index f54dc37cb2..90862fafb5 100644 --- a/packages/common-i18n/src/locales/en/space.json +++ b/packages/common-i18n/src/locales/en/space.json @@ -33,7 +33,9 @@ "generalDescription": "Change the settings for your current space here", "collaboratorDescription": "Manage collaborators of your space and set their access permission", "spaceName": "Space Name", - "spaceId": "Space ID" + "spaceId": "Space ID", + "webhooks": "Webhooks", + "webhookDescription": "Webhooks allow external services to be notified when certain events happen. When the specified events happen, we'll send a POST request to each of the URLs you provide." }, "pin": { "add": "Add to pin", diff --git a/packages/common-i18n/src/locales/en/webhook.json b/packages/common-i18n/src/locales/en/webhook.json new file mode 100644 index 0000000000..65eb8b170d --- /dev/null +++ b/packages/common-i18n/src/locales/en/webhook.json @@ -0,0 +1,87 @@ +{ + "availableWebhooks": "Available webhooks", + "form": { + "addWebhook": "Add webhook", + "notificationUrl": "Notification Url", + "contentType": "Content Type", + "secret": "Secret", + "active": "Active", + "activeDesc": "We will deliver event details when this hook is triggered.", + "webhookDesc": "We'll send a POST request to the URL below with details of any subscribed events. You can also specify which data format you'd like to receive (JSON, x-www-form-urlencoded, etc). More information can be found in our developer documentation." + }, + "moreDesc": "and {{len}} more", + "deleteConfirm": { + "title": "Delete webhook?", + "description": "This action cannot be undone. Future events will no longer be delivered to this webhook ({{notificationUrl}})." + }, + "base": { + "create": { + "title": "Create Database", + "description": "Initialize a new database instance for storing tables and data." + }, + "delete": { + "title": "Delete Database", + "description": "Permanently removes an existing database and all the data and tables it contains." + }, + "update": { + "title": "Update Database", + "description": "Update database configurations or properties, such as changing names or security settings." + } + }, + "table": { + "create": { + "title": "Create Table", + "description": "A new table is added to the database to store structured data records." + }, + "delete": { + "title": "Delete Table", + "description": "Permanently removes a table and all its data from the database." + }, + "update": { + "title": "Update Table", + "description": "Modify the structure or attributes of a table, such as adding, deleting, or modifying columns." + }, + "field": { + "create": { + "title": "Create Field", + "description": "Add new fields to the table to store data items." + }, + "delete": { + "title": "Delete Field", + "description": "Removes the specified field from the table and deletes the associated data." + }, + "update": { + "title": "Update Field", + "description": "Change the definition of an existing field, such as field name, type, etc." + } + }, + "record": { + "create": { + "title": "Create Record", + "description": "Adds a new data record to the table, populating each field value." + }, + "delete": { + "title": "Delete Record", + "description": "Removes the specified data record from the table." + }, + "update": { + "title": "Update Record", + "description": "Updates one or more field values of an existing record in a table." + } + }, + "view": { + "create": { + "title": "Create View", + "description": "Define a new view that provides a specific representation of the data for data query or presentation." + }, + "delete": { + "title": "Delete View", + "description": "Remove an existing view definition without affecting the underlying data table." + }, + "update": { + "title": "Update View", + "description": "Update the definition of the view, including the data or presentation formats involved." + } + } + } +} diff --git a/packages/common-i18n/src/locales/zh/webhook.json b/packages/common-i18n/src/locales/zh/webhook.json new file mode 100644 index 0000000000..c45f5d9dd2 --- /dev/null +++ b/packages/common-i18n/src/locales/zh/webhook.json @@ -0,0 +1,77 @@ +{ + "moreDesc": "和{{len}}个其他", + "deleteConfirm": { + "title": "删除 webhook?", + "description": "此操作无法撤消。未来的活动将不再传递到此webhook ({{notificationUrl}})." + }, + "base": { + "create": { + "title": "创建数据库", + "description": "初始化一个新的数据库实例,用于存储表和数据。" + }, + "delete": { + "title": "删除数据库", + "description": "永久移除一个现有的数据库及其包含的所有数据和表。" + }, + "update": { + "title": "修改数据库", + "description": "更新数据库配置或属性,例如修改名称或安全设置。" + } + }, + "table": { + "create": { + "title": "创建表", + "description": "在数据库中新增一个表,用于存储结构化数据记录。" + }, + "delete": { + "title": "删除表", + "description": "从数据库中永久移除一个表及其所有数据。" + }, + "update": { + "title": "修改表", + "description": "修改表的结构或属性,如添加、删除或修改列。" + }, + "field": { + "create": { + "title": "创建字段", + "description": "在表中添加新的字段,用于存储数据项。" + }, + "delete": { + "title": "删除字段", + "description": "从表中移除指定的字段,并删除相关数据。" + }, + "update": { + "title": "修改字段", + "description": "更改现有字段的定义,如字段名、类型等。" + } + }, + "record": { + "create": { + "title": "创建记录", + "description": "向表中添加新的数据记录,填充各字段值。" + }, + "delete": { + "title": "删除记录", + "description": "从表中移除指定的数据记录。" + }, + "update": { + "title": "修改记录", + "description": "更新表中一条现有记录的一个或多个字段值。" + } + }, + "view": { + "create": { + "title": "创建视图", + "description": "定义一个新的视图,为数据查询或展示提供特定的数据表示。" + }, + "delete": { + "title": "删除视图", + "description": "移除一个现有的视图定义,不影响基础数据表。" + }, + "update": { + "title": "修改视图", + "description": "更新视图的定义,包括所涉及的数据或展示格式。" + } + } + } +} diff --git a/packages/core/src/utils/id-generator.ts b/packages/core/src/utils/id-generator.ts index bd8721acd8..9a2439823a 100644 --- a/packages/core/src/utils/id-generator.ts +++ b/packages/core/src/utils/id-generator.ts @@ -51,6 +51,9 @@ export enum IdPrefix { Organization = 'org', OrganizationDepartment = 'odp', + + WebHook = 'whk', + WebHookRunHistory = 'wrh', } const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -202,3 +205,11 @@ export function generateOrganizationId() { export function generateOrganizationDepartmentId() { return IdPrefix.OrganizationDepartment + getRandomString(16); } + +export function generateWebHookId() { + return IdPrefix.WebHook + getRandomString(16); +} + +export function generateWebHookRunHistoryId() { + return IdPrefix.WebHookRunHistory + getRandomString(16); +} diff --git a/packages/db-main-prisma/prisma/postgres/migrations/migration_lock.toml b/packages/db-main-prisma/prisma/postgres/migrations/migration_lock.toml index 648c57fd59..fbffa92c2b 100644 --- a/packages/db-main-prisma/prisma/postgres/migrations/migration_lock.toml +++ b/packages/db-main-prisma/prisma/postgres/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) +# It should be added in your version-control system (i.e. Git) provider = "postgresql" \ No newline at end of file diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index 2842cb334f..1c8dcca3e7 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -513,4 +513,38 @@ model CommentSubscription { @@unique([tableId, recordId]) @@index([tableId, recordId]) @@map("comment_subscription") -} \ No newline at end of file +} + +model Webhook { + id String @id @default(cuid()) + url String @map("url") + spaceId String @map("space_id") + baseIds String? @map("base_ids") + method String @map("method") + contentType String @map("content_type") + secret String? @map("secret") + events String? @map("events") + isEnabled Boolean @default(false) @map("is_enabled") + createdBy String @map("create_by") + createdTime DateTime @default(now()) @map("created_time") + lastModifiedBy String? @map("last_modified_by") + lastModifiedTime DateTime? @updatedAt @map("last_modified_time") + + @@map("webhooks") +} + +model WebhookRunHistory { + id String @id @default(cuid()) + webhookId String @map("webhook_id") + event String @map("event") + status String @map("status") + request String @map("request") + response String @map("response") + isError Boolean @default(false) @map("is_error") + errorMsg String? @map("error_msg") + createdBy String @map("create_by") + createdTime DateTime @default(now()) @map("created_time") + finishedTime DateTime? @map("finished_time") + + @@map("webhook_run_histories") +} diff --git a/packages/db-main-prisma/prisma/sqlite/schema.prisma b/packages/db-main-prisma/prisma/sqlite/schema.prisma index 7053cf36d4..4fd17fb01a 100644 --- a/packages/db-main-prisma/prisma/sqlite/schema.prisma +++ b/packages/db-main-prisma/prisma/sqlite/schema.prisma @@ -513,4 +513,38 @@ model CommentSubscription { @@unique([tableId, recordId]) @@index([tableId, recordId]) @@map("comment_subscription") -} \ No newline at end of file +} + +model Webhook { + id String @id @default(cuid()) + url String @map("url") + spaceId String @map("space_id") + baseIds String? @map("base_ids") + method String @map("method") + contentType String @map("content_type") + secret String? @map("secret") + events String? @map("events") + isEnabled Boolean @default(false) @map("is_enabled") + createdBy String @map("create_by") + createdTime DateTime @default(now()) @map("created_time") + lastModifiedBy String? @map("last_modified_by") + lastModifiedTime DateTime? @updatedAt @map("last_modified_time") + + @@map("webhooks") +} + +model WebhookRunHistory { + id String @id @default(cuid()) + webhookId String @map("webhook_id") + event String @map("event") + status String @map("status") + request String @map("request") + response String @map("response") + isError Boolean @default(false) @map("is_error") + errorMsg String? @map("error_msg") + createdBy String @map("create_by") + createdTime DateTime @default(now()) @map("created_time") + finishedTime DateTime? @map("finished_time") + + @@map("webhook_run_histories") +} diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma index 1dd8083376..98a3116f3a 100644 --- a/packages/db-main-prisma/prisma/template.prisma +++ b/packages/db-main-prisma/prisma/template.prisma @@ -513,4 +513,38 @@ model CommentSubscription { @@unique([tableId, recordId]) @@index([tableId, recordId]) @@map("comment_subscription") -} \ No newline at end of file +} + +model Webhook { + id String @id @default(cuid()) + url String @map("url") + spaceId String @map("space_id") + baseIds String? @map("base_ids") + method String @map("method") + contentType String @map("content_type") + secret String? @map("secret") + events String? @map("events") + isEnabled Boolean @default(false) @map("is_enabled") + createdBy String @map("create_by") + createdTime DateTime @default(now()) @map("created_time") + lastModifiedBy String? @map("last_modified_by") + lastModifiedTime DateTime? @updatedAt @map("last_modified_time") + + @@map("webhooks") +} + +model WebhookRunHistory { + id String @id @default(cuid()) + webhookId String @map("webhook_id") + event String @map("event") + status String @map("status") + request String @map("request") + response String @map("response") + isError Boolean @default(false) @map("is_error") + errorMsg String? @map("error_msg") + createdBy String @map("create_by") + createdTime DateTime @default(now()) @map("created_time") + finishedTime DateTime? @map("finished_time") + + @@map("webhook_run_histories") +} diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index c83cf6a5e5..4051d19fb4 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -34,3 +34,4 @@ export * from './comment'; export * from './organization'; export * from './ai'; export * from './integrity'; +export * from './webhook'; diff --git a/packages/openapi/src/webhook/create.ts b/packages/openapi/src/webhook/create.ts new file mode 100644 index 0000000000..df2e6b396c --- /dev/null +++ b/packages/openapi/src/webhook/create.ts @@ -0,0 +1,50 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import type { IWebhookVo } from './get'; +import { webhookVoSchema } from './get'; +import { ContentType } from './types'; + +export const CREATE_WEBHOOK = '{spaceId}/webhook'; + +export const createWebhookRoSchema = z.object({ + spaceId: z.string(), + baseIds: z.string().array().optional(), + url: z.string().url(), + contentType: z.nativeEnum(ContentType), + secret: z.string().optional(), + events: z.string().array().min(1), + isEnabled: z.coerce.boolean(), +}); + +export type ICreateWebhookRo = z.infer; + +export const CreatWebhookRoute = registerRoute({ + method: 'post', + path: CREATE_WEBHOOK, + description: 'Create web hook', + request: { + body: { + content: { + 'application/json': { + schema: createWebhookRoSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Return web hook', + content: { + 'application/json': { + schema: webhookVoSchema, + }, + }, + }, + }, + tags: ['webhook'], +}); + +export const createWebhook = async (body: ICreateWebhookRo) => { + return axios.post(urlBuilder(CREATE_WEBHOOK, { spaceId: body.spaceId }), body); +}; diff --git a/packages/openapi/src/webhook/delete.ts b/packages/openapi/src/webhook/delete.ts new file mode 100644 index 0000000000..138d1a2144 --- /dev/null +++ b/packages/openapi/src/webhook/delete.ts @@ -0,0 +1,32 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const DELETE_WEBHOOK = '{spaceId}/webhook/{webhookId}'; + +export const DeleteWebhookRoute = registerRoute({ + method: 'delete', + path: DELETE_WEBHOOK, + description: 'Delete web hook', + request: { + params: z.object({ + id: z.string(), + }), + }, + responses: { + 200: { + description: 'Deleted successfully', + }, + }, + tags: ['webhook'], +}); + +export const deleteWebhook = async ({ + spaceId, + webhookId, +}: { + spaceId: string; + webhookId: string; +}) => { + return axios.delete(urlBuilder(DELETE_WEBHOOK, { spaceId, webhookId })); +}; diff --git a/packages/openapi/src/webhook/get-list.ts b/packages/openapi/src/webhook/get-list.ts new file mode 100644 index 0000000000..488b104343 --- /dev/null +++ b/packages/openapi/src/webhook/get-list.ts @@ -0,0 +1,34 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import type { z } from '../zod'; +import { webhookVoSchema } from './get'; + +export const GET_WEBHOOK_LIST = '{spaceId}/webhook'; + +export const webhookListVoSchema = webhookVoSchema.array().openapi({ + description: 'The list of webhook', +}); + +export type IWebhookListVo = z.infer; + +export const GetWebhookListRoute = registerRoute({ + method: 'get', + path: GET_WEBHOOK_LIST, + description: 'Get webhook list', + request: {}, + responses: { + 200: { + description: 'Returns the list of webhook', + content: { + 'application/json': { + schema: webhookListVoSchema, + }, + }, + }, + }, + tags: ['webhook'], +}); + +export const getWebhookList = async (spaceId: string) => { + return axios.get(urlBuilder(GET_WEBHOOK_LIST, { spaceId })); +}; diff --git a/packages/openapi/src/webhook/get-run-history-list.ts b/packages/openapi/src/webhook/get-run-history-list.ts new file mode 100644 index 0000000000..2ad0611c2f --- /dev/null +++ b/packages/openapi/src/webhook/get-run-history-list.ts @@ -0,0 +1,52 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { webhookRunHistoryVoSchema } from './get-run-history'; + +export const GET_WEBHOOK_RUN_HISTORY_LIST = '/webhook/run-history'; + +export const getWebhookRunHistoryListQuerySchema = z.object({ + cursor: z.string().nullish(), +}); + +export type IGetWebhookRunHistoryListQuery = z.infer; + +export const webhookRunHistoryListVoSchema = z.array(webhookRunHistoryVoSchema); +export type IWebhookRunHistoryList = z.infer; + +export const webhookRunHistoriesVoSchema = z + .object({ + runHistories: webhookRunHistoryListVoSchema, + nextCursor: z.string().nullish(), + }) + .openapi({ + description: 'The list of webhook run history', + }); + +export type IWebhookRunHistoriesVo = z.infer; + +export const GetWebhookRunHistoryListRoute = registerRoute({ + method: 'get', + path: GET_WEBHOOK_RUN_HISTORY_LIST, + description: 'Get webhook run history list', + request: { + query: getWebhookRunHistoryListQuerySchema, + }, + responses: { + 200: { + description: 'Returns the list of webhook run history', + content: { + 'application/json': { + schema: webhookRunHistoriesVoSchema, + }, + }, + }, + }, + tags: ['webhook'], +}); + +export const getWebhookRunHistoryList = async (query: IGetWebhookRunHistoryListQuery) => { + return axios.get(urlBuilder(GET_WEBHOOK_RUN_HISTORY_LIST), { + params: query, + }); +}; diff --git a/packages/openapi/src/webhook/get-run-history.ts b/packages/openapi/src/webhook/get-run-history.ts new file mode 100644 index 0000000000..2ef94cfcea --- /dev/null +++ b/packages/openapi/src/webhook/get-run-history.ts @@ -0,0 +1,44 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const GET_WEBHOOK_RUN_HISTORY = '/webhook/run-history/{runHistoryId}'; + +export const webhookRunHistoryVoSchema = z.object({ + id: z.string(), + webhookId: z.string(), + event: z.string(), + status: z.string(), + request: z.unknown(), + response: z.unknown(), + createdTime: z.string(), + finishedTime: z.string().optional(), +}); + +export type IWebhookRunHistoryVo = z.infer; + +export const GetWebhookRunHistoryRoute = registerRoute({ + method: 'get', + path: GET_WEBHOOK_RUN_HISTORY, + description: 'Get webhook run history by id', + request: { + params: z.object({ + runHistoryId: z.string(), + }), + }, + responses: { + 200: { + description: 'Return the webhook run history by id', + content: { + 'application/json': { + schema: webhookRunHistoryVoSchema, + }, + }, + }, + }, + tags: ['webhook'], +}); + +export const getWebhookRunHistoryById = async (runHistoryId: string) => { + return axios.get(urlBuilder(GET_WEBHOOK_RUN_HISTORY, { runHistoryId })); +}; diff --git a/packages/openapi/src/webhook/get.ts b/packages/openapi/src/webhook/get.ts new file mode 100644 index 0000000000..f0a8c87fe2 --- /dev/null +++ b/packages/openapi/src/webhook/get.ts @@ -0,0 +1,46 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { ContentType } from './types'; + +export const GET_WEBHOOK = '/webhook/{webHookId}'; + +export const webhookVoSchema = z.object({ + id: z.string(), + url: z.string(), + contentType: z.nativeEnum(ContentType), + events: z.string().array().min(1), + hasSecret: z.boolean(), + isEnabled: z.boolean(), + createdTime: z.string(), + lastModifiedTime: z.string().optional(), + lastStatus: z.string().optional(), +}); + +export type IWebhookVo = z.infer; + +export const GetWebhookRoute = registerRoute({ + method: 'get', + path: GET_WEBHOOK, + description: 'Get webhook', + request: { + params: z.object({ + webHookId: z.string(), + }), + }, + responses: { + 200: { + description: 'Return webhook', + content: { + 'application/json': { + schema: webhookVoSchema, + }, + }, + }, + }, + tags: ['webhook'], +}); + +export const getWebhookById = async (webHookId: string) => { + return axios.get(urlBuilder(GET_WEBHOOK, { webHookId })); +}; diff --git a/packages/openapi/src/webhook/index.ts b/packages/openapi/src/webhook/index.ts new file mode 100644 index 0000000000..8e063e7285 --- /dev/null +++ b/packages/openapi/src/webhook/index.ts @@ -0,0 +1,8 @@ +export * from './create'; +export * from './delete'; +export * from './update'; +export * from './get'; +export * from './get-list'; +export * from './get-run-history'; +export * from './get-run-history-list'; +export * from './types'; diff --git a/packages/openapi/src/webhook/types.ts b/packages/openapi/src/webhook/types.ts new file mode 100644 index 0000000000..14218bea7f --- /dev/null +++ b/packages/openapi/src/webhook/types.ts @@ -0,0 +1,9 @@ +export enum WebhookRunStatus { + Running = 'running', + Finished = 'finished', +} + +export enum ContentType { + Json = 'application/json', + FormUrlencoded = 'application/x-www-form-urlencoded', +} diff --git a/packages/openapi/src/webhook/update.ts b/packages/openapi/src/webhook/update.ts new file mode 100644 index 0000000000..fbde674f5d --- /dev/null +++ b/packages/openapi/src/webhook/update.ts @@ -0,0 +1,45 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { createWebhookRoSchema } from './create'; +import type { IWebhookVo } from './get'; +import { webhookVoSchema } from './get'; + +export const UPDATE_WEBHOOK = '/webhook/{id}'; + +export const updateWebhookRoSchema = createWebhookRoSchema; + +export type IUpdateWebhookRo = z.infer; + +export const UpdateWebhookRoute = registerRoute({ + method: 'put', + path: UPDATE_WEBHOOK, + description: 'Update web hook', + request: { + params: z.object({ + id: z.string(), + }), + body: { + content: { + 'application/json': { + schema: updateWebhookRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Return the updated web hook', + content: { + 'application/json': { + schema: webhookVoSchema, + }, + }, + }, + }, + tags: ['webhook'], +}); + +export const updateWebhook = async (id: string, body: IUpdateWebhookRo) => { + return axios.put(urlBuilder(UPDATE_WEBHOOK, { id }), body); +}; diff --git a/packages/sdk/src/config/react-query-keys.ts b/packages/sdk/src/config/react-query-keys.ts index 0fa1d661e9..d4a7142ea6 100644 --- a/packages/sdk/src/config/react-query-keys.ts +++ b/packages/sdk/src/config/react-query-keys.ts @@ -144,4 +144,6 @@ export const ReactQueryKeys = { getDepartmentUsers: (ro?: IGetDepartmentUserRo) => ['department-users', ro] as const, getOrganizationMe: () => ['organization-me'] as const, + + webhookList: (spaceId: string) => ['webhook-list', spaceId] as const, }; diff --git a/packages/ui-lib/src/shadcn/ui/progress.tsx b/packages/ui-lib/src/shadcn/ui/progress.tsx index a8431d4e23..918af7ac88 100644 --- a/packages/ui-lib/src/shadcn/ui/progress.tsx +++ b/packages/ui-lib/src/shadcn/ui/progress.tsx @@ -8,7 +8,7 @@ import { cn } from '../utils'; const Progress = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( +>(({ className, value, max = 100, ...props }, ref) => ( ));