diff --git a/packages/nocodb-nest/src/app.module.ts b/packages/nocodb-nest/src/app.module.ts index c32a414cf6..1c5d4b7588 100644 --- a/packages/nocodb-nest/src/app.module.ts +++ b/packages/nocodb-nest/src/app.module.ts @@ -16,9 +16,10 @@ import { SortsModule } from './modules/sorts/sorts.module'; import { ColumnsModule } from './modules/columns/columns.module'; import { ViewColumnsModule } from './modules/view-columns/view-columns.module'; import { BasesModule } from './modules/bases/bases.module'; +import { HooksModule } from './modules/hooks/hooks.module'; @Module({ - imports: [AuthModule, UsersModule, UtilsModule, ProjectsModule, TablesModule, ViewsModule, FiltersModule, SortsModule, ColumnsModule, ViewColumnsModule, BasesModule], + imports: [AuthModule, UsersModule, UtilsModule, ProjectsModule, TablesModule, ViewsModule, FiltersModule, SortsModule, ColumnsModule, ViewColumnsModule, BasesModule, HooksModule], controllers: [], providers: [Connection, MetaService, JwtStrategy, ExtractProjectIdMiddleware], exports: [Connection, MetaService], diff --git a/packages/nocodb-nest/src/modules/hooks/hooks.controller.spec.ts b/packages/nocodb-nest/src/modules/hooks/hooks.controller.spec.ts new file mode 100644 index 0000000000..530f67901f --- /dev/null +++ b/packages/nocodb-nest/src/modules/hooks/hooks.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HooksController } from './hooks.controller'; +import { HooksService } from './hooks.service'; + +describe('HooksController', () => { + let controller: HooksController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HooksController], + providers: [HooksService], + }).compile(); + + controller = module.get(HooksController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/nocodb-nest/src/modules/hooks/hooks.controller.ts b/packages/nocodb-nest/src/modules/hooks/hooks.controller.ts new file mode 100644 index 0000000000..c787f3d49d --- /dev/null +++ b/packages/nocodb-nest/src/modules/hooks/hooks.controller.ts @@ -0,0 +1,112 @@ +import { + Body, + Controller, + Request, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { HookReqType, HookTestReqType, HookType } from 'nocodb-sdk'; +import { PagedResponseImpl } from '../../helpers/PagedResponse'; +import { + Acl, + ExtractProjectIdMiddleware, +} from '../../middlewares/extract-project-id/extract-project-id.middleware'; +import { HooksService } from './hooks.service'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('hooks') +@UseGuards(ExtractProjectIdMiddleware, AuthGuard('jwt')) +export class HooksController { + constructor(private readonly hooksService: HooksService) {} + + @Get('/api/v1/db/meta/tables/:tableId/hooks') + @Acl('hookList') + async hookList(@Param('tableId') tableId: string) { + return new PagedResponseImpl(await this.hooksService.hookList({ tableId })); + } + + @Post('/api/v1/db/meta/tables/:tableId/hooks') + @Acl('hookCreate') + async hookCreate( + @Param('tableId') tableId: string, + @Body() body: HookReqType, + ) { + const hook = await this.hooksService.hookCreate({ + hook: body, + tableId, + }); + return hook; + } + + @Delete('/api/v1/db/meta/hooks/:hookId') + @Acl('hookDelete') + async hookDelete(@Param('hookId') hookId: string) { + return await this.hooksService.hookDelete({ hookId }); + } + + @Patch('/api/v1/db/meta/hooks/:hookId') + @Acl('hookUpdate') + async hookUpdate(@Param('hookId') hookId: string, @Body() body: HookReqType) { + return; + await this.hooksService.hookUpdate({ hookId, hook: body }); + } + + @Post('/api/v1/db/meta/tables/:tableId/hooks/test') + @Acl('hookTest') + async hookTest(@Body() body: HookTestReqType, @Request() req: any) { + try { + await this.hooksService.hookTest({ + hookTest: { + ...body, + payload: { + ...body.payload, + user: (req as any)?.user, + }, + }, + tableId: req.params.tableId, + }); + return { msg: 'The hook has been tested successfully' }; + } catch (e) { + console.error(e); + throw e; + } + } + + @Get( + '/api/v1/db/meta/tables/:tableId/hooks/samplePayload/:operation/:version', + ) + @Acl('tableSampleData') + async tableSampleData( + @Param('tableId') tableId: string, + @Param('operation') operation: HookType['operation'], + @Param('version') version: HookType['version'], + ) { + return; + await this.hooksService.tableSampleData({ + tableId, + operation, + version, + }); + } + + @Get('/api/v1/db/meta/hooks/:hookId/logs') + @Acl('hookLogList') + async hookLogList(@Param('hookId') hookId: string, @Request() req: any) { + return new PagedResponseImpl( + await this.hooksService.hookLogList({ + query: req.query, + hookId, + }), + { + ...req.query, + count: await this.hooksService.hookLogCount({ + hookId, + }), + }, + ); + } +} diff --git a/packages/nocodb-nest/src/modules/hooks/hooks.module.ts b/packages/nocodb-nest/src/modules/hooks/hooks.module.ts new file mode 100644 index 0000000000..feb1b6b5bd --- /dev/null +++ b/packages/nocodb-nest/src/modules/hooks/hooks.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HooksService } from './hooks.service'; +import { HooksController } from './hooks.controller'; + +@Module({ + controllers: [HooksController], + providers: [HooksService] +}) +export class HooksModule {} diff --git a/packages/nocodb-nest/src/modules/hooks/hooks.service.spec.ts b/packages/nocodb-nest/src/modules/hooks/hooks.service.spec.ts new file mode 100644 index 0000000000..c9d9221fad --- /dev/null +++ b/packages/nocodb-nest/src/modules/hooks/hooks.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HooksService } from './hooks.service'; + +describe('HooksService', () => { + let service: HooksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HooksService], + }).compile(); + + service = module.get(HooksService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/nocodb-nest/src/modules/hooks/hooks.service.ts b/packages/nocodb-nest/src/modules/hooks/hooks.service.ts new file mode 100644 index 0000000000..1916b8cb5f --- /dev/null +++ b/packages/nocodb-nest/src/modules/hooks/hooks.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common'; +import { HookReqType, HookTestReqType, HookType } from 'nocodb-sdk'; +import { validatePayload } from '../../helpers'; +import { NcError } from '../../helpers/catchError'; +import { + populateSamplePayload, + populateSamplePayloadV2, +} from '../../helpers/populateSamplePayload'; +import { invokeWebhook } from '../../helpers/webhookHelpers'; +import { Hook, HookLog, Model } from '../../models'; +import { T } from 'nc-help'; + +@Injectable() +export class HooksService { + validateHookPayload(notificationJsonOrObject: string | Record) { + let notification: { type?: string } = {}; + try { + notification = + typeof notificationJsonOrObject === 'string' + ? JSON.parse(notificationJsonOrObject) + : notificationJsonOrObject; + } catch {} + + if (notification.type !== 'URL' && process.env.NC_CLOUD === 'true') { + NcError.badRequest('Only URL notification is supported'); + } + } + + async hookList(param: { tableId: string }) { + return await Hook.list({ fk_model_id: param.tableId }); + } + + async hookLogList(param: { query: any; hookId: string }) { + return await HookLog.list({ fk_hook_id: param.hookId }, param.query); + } + + async hookCreate(param: { tableId: string; hook: HookReqType }) { + validatePayload('swagger.json#/components/schemas/HookReq', param.hook); + + this.validateHookPayload(param.hook.notification); + + const hook = await Hook.insert({ + ...param.hook, + fk_model_id: param.tableId, + } as any); + + T.emit('evt', { evt_type: 'webhooks:created' }); + + return hook; + } + + async hookDelete(param: { hookId: string }) { + T.emit('evt', { evt_type: 'webhooks:deleted' }); + await Hook.delete(param.hookId); + return true; + } + + async hookUpdate(param: { hookId: string; hook: HookReqType }) { + validatePayload('swagger.json#/components/schemas/HookReq', param.hook); + + T.emit('evt', { evt_type: 'webhooks:updated' }); + + this.validateHookPayload(param.hook.notification); + + return await Hook.update(param.hookId, param.hook); + } + + async hookTest(param: { tableId: string; hookTest: HookTestReqType }) { + validatePayload( + 'swagger.json#/components/schemas/HookTestReq', + param.hookTest, + ); + + this.validateHookPayload(param.hookTest.hook?.notification); + + const model = await Model.getByIdOrName({ id: param.tableId }); + + T.emit('evt', { evt_type: 'webhooks:tested' }); + + const { + hook, + payload: { data, user }, + } = param.hookTest; + try { + await invokeWebhook( + new Hook(hook), + model, + null, + null, + data, + user, + (hook as any)?.filters, + true, + true, + ); + } catch (e) { + throw e; + } + + return true; + } + + async tableSampleData(param: { + tableId: string; + operation: HookType['operation']; + version: HookType['version']; + }) { + const model = await Model.getByIdOrName({ id: param.tableId }); + + if (param.version === 'v1') { + return await populateSamplePayload(model, false, param.operation); + } + return await populateSamplePayloadV2(model, false, param.operation); + } + + async hookLogCount(param: { hookId: string }) { + return await HookLog.count({ hookId: param.hookId }); + } +}