From 65f1a4fa9c01a38af7265ccd8ba48e6a5d47fff1 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Tue, 2 Jul 2024 18:17:51 +0530 Subject: [PATCH 01/11] Added support for multiple segments. (#243) --- .github/workflows/CI-tests.yml | 4 +- Dockerfile | 8 +- src/modules/bot/bot.controller.ts | 6 +- src/modules/bot/bot.service.spec.ts | 41 ++++++++-- src/modules/bot/bot.service.ts | 84 ++++++++++----------- src/modules/service/http-get.resolver.ts | 4 +- src/modules/service/service.service.spec.ts | 20 ++++- src/modules/service/service.service.ts | 3 +- 8 files changed, 109 insertions(+), 61 deletions(-) diff --git a/.github/workflows/CI-tests.yml b/.github/workflows/CI-tests.yml index f6fed45..8418d1a 100644 --- a/.github/workflows/CI-tests.yml +++ b/.github/workflows/CI-tests.yml @@ -15,10 +15,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v2 with: - node-version: 16.18 + node-version: 18 - name: Install Dependencies - run: yarn install --frozen-lockfile + run: yarn install - name: Generate Prisma Client and Test run: npx prisma generate && yarn test 2>&1 | tee test-report.txt diff --git a/Dockerfile b/Dockerfile index 7d8c7e8..b2e27f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM node:16 AS install +FROM node:18 AS install WORKDIR /app COPY package.json ./ RUN yarn install -FROM node:16 as build +FROM node:18 as build WORKDIR /app COPY prisma ./prisma/ RUN npx prisma generate @@ -13,11 +13,11 @@ COPY --from=install /app/node_modules ./node_modules COPY . . RUN npm run build -FROM node:16 +FROM node:18 WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/package*.json ./ COPY --from=build /app/prisma ./prisma COPY --from=build /app/node_modules ./node_modules EXPOSE 3002 -CMD [ "npm", "run", "start:migrate:prod" ] \ No newline at end of file +CMD [ "npm", "run", "start:migrate:prod" ] diff --git a/src/modules/bot/bot.controller.ts b/src/modules/bot/bot.controller.ts index 8844718..eb5345f 100644 --- a/src/modules/bot/bot.controller.ts +++ b/src/modules/bot/bot.controller.ts @@ -281,8 +281,8 @@ export class BotController { AddOwnerInfoInterceptor, AddROToResponseInterceptor, ) - @Get('/getAllUsers/:id/:page?') - async getAllUsers(@Param('id') id: string, @Headers() headers, @Param('page') page?: number) { + @Get('/getAllUsers/:id/:segment/:page?') + async getAllUsers(@Param('id') id: string, @Headers() headers, @Param('segment') segment: number, @Param('page') page?: number) { const bot: Prisma.BotGetPayload<{ include: { users: { @@ -300,7 +300,7 @@ export class BotController { }> | null = await this.botService.findOne(id); bot ? console.log('Users for the bot', bot['users']) : ''; if (bot && bot.users[0].all) { - const users = await this.service.resolve(bot.users[0].all, page, bot.ownerID, headers['conversation-authorization']); + const users = await this.service.resolve(bot.users[0].all, segment, page, bot.ownerID, headers['conversation-authorization']); return users; } return bot; diff --git a/src/modules/bot/bot.service.spec.ts b/src/modules/bot/bot.service.spec.ts index 9537738..e5d7f6f 100644 --- a/src/modules/bot/bot.service.spec.ts +++ b/src/modules/bot/bot.service.spec.ts @@ -351,7 +351,7 @@ const mockBotsResolved = [{ }]; const mockConfig = { - "url": "http://mytesturl?", + "url": "http://mytesturl/segments/1/mentors?deepLink=nipunlakshya://chatbot?botId=testbotid", "type": "GET", "cadence": { "perPage": 20, @@ -373,7 +373,8 @@ let deletedIds: any[] = [] describe('BotService', () => { let botService: BotService; let configService: ConfigService; - jest.setTimeout(15000); + // Multiple segments need more time to trigger. + jest.setTimeout(30000); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -441,19 +442,19 @@ describe('BotService', () => { }); it('bot picks totalCount from config url', async () => { - fetchMock.getOnce('http://mytesturl/count', { + fetchMock.getOnce('http://mytesturl/segments/1/mentors/count', { totalCount: 100 }); for (let x = 1; x <= 5; x++) { fetchMock.getOnce( - `${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=${'testId'}&page=${x}`, + `${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=${'testId'}&page=${x}&segment=1`, '' ); } await botService.start('testId', mockConfig, 'testAuthToken'); for (let x = 1; x <= 5; x++) { expect(fetchMock.called( - `${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=${'testId'}&page=${x}` + `${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=${'testId'}&page=${x}&segment=1` )).toBe(true); } fetchMock.restore(); @@ -559,12 +560,12 @@ describe('BotService', () => { fetchMock.getOnce('http://testSegmentUrl/segments/1/mentors/count', { totalCount: 1 }); - fetchMock.getOnce(`${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=testBotId&page=1`, (url, options) => { + fetchMock.getOnce(`${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=testBotId&page=1&segment=1`, (url, options) => { submittedToken = options.headers ? options.headers['conversation-authorization'] : ''; return true; }); await botService.start(botId, mockBotsDb[0].users[0].all.config,'testAuthToken'); - expect(fetchMock.called(`${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=testBotId&page=1`)).toBe(true); + expect(fetchMock.called(`${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=testBotId&page=1&segment=1`)).toBe(true); expect(submittedToken).toEqual('testAuthToken'); fetchMock.restore(); }); @@ -808,4 +809,30 @@ describe('BotService', () => { .toThrowError(`Required type for 'meta' is JSON, provided: 'string'`); fetchMock.restore(); }); + + it('bot start triggers multiple segments', async () => { + const segments = [1, 99, 4]; + for (let segment of segments) { + fetchMock.getOnce(`http://mytesturl/segments/${segment}/mentors/count`, { + totalCount: 100 + }); + for (let x = 1; x <= 5; x++) { + fetchMock.getOnce( + `${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=${'testId'}&page=${x}&segment=${segment}`, + '' + ); + } + } + const mockConfigCopy = JSON.parse(JSON.stringify(mockConfig)); + mockConfigCopy.url = `http://mytesturl/segments/${segments.join(",")}/mentors?deepLink=nipunlakshya://chatbot?botId=testbotid`; + await botService.start('testId', mockConfigCopy, 'testAuthToken'); + for (let segment of segments) { + for (let x = 1; x <= 5; x++) { + expect(fetchMock.called( + `${configService.get('UCI_CORE_BASE_URL')}/campaign/start?campaignId=${'testId'}&page=${x}&segment=${segment}` + )).toBe(true); + } + } + fetchMock.restore(); + }); }); diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index c5ef412..38c3704 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -89,50 +89,50 @@ export class BotService { this.logger.log(`BotService::start: Called with id: ${id} and config: ${JSON.stringify(config)}`); const pageSize: number = config.cadence.perPage; const segmentUrl: string = config.url; - const userCountUrl = `${segmentUrl.substring(0, segmentUrl.indexOf('?'))}/count`; - this.logger.log(`BotService::start: Fetching total count from ${userCountUrl}`); - const userCount: number = await fetch( - userCountUrl, - { - //@ts-ignore - timeout: 5000, - headers: { 'conversation-authorization': conversationToken } - } - ) - .then(resp => resp.json()) - .then(resp => { - if (resp.totalCount) { - this.logger.log(`BotService::start: Fetched total count of users: ${resp.totalCount}`); - return resp.totalCount; - } - else { - this.logger.error(`BotService::start: Failed to fetch total count of users, reason: Response did not have 'totalCount'.`); - throw new HttpException( - 'Failed to get user count', - HttpStatus.INTERNAL_SERVER_ERROR - ); - } - }) - .catch(err => { - this.logger.error(`BotService::start: Failed to fetch total count of users, reason: ${err}`); - throw new HttpException( - err, - HttpStatus.INTERNAL_SERVER_ERROR - ); - }); - let pages = Math.ceil(userCount / pageSize); - this.logger.log(`BotService::start: Total pages: ${pages}`); + const regex = /\/segments\/([\d,]+)/; + const matched = segmentUrl.match(regex); + if (!matched || !matched[1]) { + throw new BadRequestException('Segment Url invalid.'); + } + const segments = matched[1].split(',').map(Number); const promisesFunc: string[] = []; - for (let page = 1; page <= pages; page++) { - this.logger.log( - `BotService::start: Calling endpoint: ${this.configService.get( + for (let segment of segments) { + const userCountUrl = `${segmentUrl.substring(0, segmentUrl.indexOf('/segments/'))}/segments/${segment}/mentors/count`; + this.logger.log(`BotService::start: Fetching total count from ${userCountUrl}`); + const userCount: number = await fetch( + userCountUrl, + { + //@ts-ignore + timeout: 5000, + headers: { 'conversation-authorization': conversationToken } + } + ) + .then(resp => resp.json()) + .then(resp => { + if (resp.totalCount) { + this.logger.log(`BotService::start: Fetched total count of users: ${resp.totalCount}`); + return resp.totalCount; + } + else { + this.logger.error(`BotService::start: Failed to fetch total count of users, reason: Response did not have 'totalCount'.`); + } + }) + .catch(err => { + this.logger.error(`BotService::start: Failed to fetch total count of users, reason: ${err}`); + }); + let pages = Math.ceil(userCount / pageSize); + this.logger.log(`BotService::start: Segment: ${segment} Total pages: ${pages}`); + for (let page = 1; page <= pages; page++) { + this.logger.log( + `BotService::start: Calling endpoint: ${this.configService.get( + 'UCI_CORE_BASE_URL', + )}/campaign/start?campaignId=${id}&page=${page}`, + ); + const url = `${this.configService.get( 'UCI_CORE_BASE_URL', - )}/campaign/start?campaignId=${id}&page=${page}`, - ); - const url = `${this.configService.get( - 'UCI_CORE_BASE_URL', - )}/campaign/start?campaignId=${id}&page=${page}`; - promisesFunc.push(url); + )}/campaign/start?campaignId=${id}&page=${page}&segment=${segment}`; + promisesFunc.push(url); + } } let promises = promisesFunc.map((url) => { return limit(() => diff --git a/src/modules/service/http-get.resolver.ts b/src/modules/service/http-get.resolver.ts index 9a1675c..3ac9db1 100644 --- a/src/modules/service/http-get.resolver.ts +++ b/src/modules/service/http-get.resolver.ts @@ -67,9 +67,12 @@ export class GetRequestResolverService { queryType: ServiceQueryType, getRequestConfig: GetRequestConfig, user: string | null, + segment: number, page: number | undefined, conversationToken: string ): Promise { + const regex = /\/segments\/[\d,]+/; + let userFetchUrl = getRequestConfig.url.replace(regex, `/segments/${segment}`); this.logger.debug( `Resolving ${queryType}, ${JSON.stringify(getRequestConfig.url)}`, ); @@ -87,7 +90,6 @@ export class GetRequestResolverService { // const variables = getRequestConfig.verificationParams; const errorNotificationWebhook = getRequestConfig.errorNotificationWebhook; this.logger.debug(`Headers: ${JSON.stringify(headers)}`); - let userFetchUrl = getRequestConfig.url; if (getRequestConfig.cadence.perPage != undefined && page != undefined) { const pageSize = getRequestConfig.cadence.perPage; const offset = pageSize * (page - 1); diff --git a/src/modules/service/service.service.spec.ts b/src/modules/service/service.service.spec.ts index d7da7a9..a275c2d 100644 --- a/src/modules/service/service.service.spec.ts +++ b/src/modules/service/service.service.spec.ts @@ -177,9 +177,27 @@ describe('ServiceService', () => { } }; }); - await serviceService.resolve(mockBotsDb[0].users[0].all, 1, mockBotsDb[0].ownerID, 'testAuthToken'); + await serviceService.resolve(mockBotsDb[0].users[0].all, 1, 1, mockBotsDb[0].ownerID, 'testAuthToken'); expect(fetchMock.called('http://testSegmentUrl/segments/1/mentors?deepLink=nipunlakshya://chatbot&limit=1&offset=0')).toBe(true); expect(submittedToken).toEqual('testAuthToken'); fetchMock.restore(); }); + + it('service passes segment correctly', async () => { + let submittedToken: string | null = ''; + fetchMock.getOnce('http://testSegmentUrl/segments/99/mentors?deepLink=nipunlakshya://chatbot&limit=1&offset=0', (url, options) => { + if (options.headers) { + submittedToken = new Headers(options.headers).get('conversation-authorization'); + } + return { + data : { + users: [] + } + }; + }); + await serviceService.resolve(mockBotsDb[0].users[0].all, 99, 1, mockBotsDb[0].ownerID, 'testAuthToken'); + expect(fetchMock.called('http://testSegmentUrl/segments/99/mentors?deepLink=nipunlakshya://chatbot&limit=1&offset=0')).toBe(true); + expect(submittedToken).toEqual('testAuthToken'); + fetchMock.restore(); + }); }); diff --git a/src/modules/service/service.service.ts b/src/modules/service/service.service.ts index eafe1c1..db8a656 100644 --- a/src/modules/service/service.service.ts +++ b/src/modules/service/service.service.ts @@ -15,7 +15,7 @@ export class ServiceService { this.logger = new Logger('ServiceService'); } - async resolve(service: Service, page: number | undefined, owner: string | null, conversationToken: string) { + async resolve(service: Service, segment: number, page: number | undefined, owner: string | null, conversationToken: string) { const startTime = performance.now(); this.logger.log(`ServiceService::resolve: Resolving users. Page: ${page}`); if (service.type === 'gql') { @@ -33,6 +33,7 @@ export class ServiceService { ServiceQueryType.all, service.config as GqlConfig, owner, + segment, page, conversationToken ); From 786ffbf28af8ccd9044f914914dc2e9eada61946 Mon Sep 17 00:00:00 2001 From: Chinmoy Chakraborty Date: Mon, 15 Jul 2024 17:52:20 +0530 Subject: [PATCH 02/11] Switch back to node 16 due to fetch api changes. --- .github/workflows/CI-tests.yml | 6 +++--- Dockerfile | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/CI-tests.yml b/.github/workflows/CI-tests.yml index 8418d1a..dc02978 100644 --- a/.github/workflows/CI-tests.yml +++ b/.github/workflows/CI-tests.yml @@ -15,10 +15,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v2 with: - node-version: 18 + node-version: 16 - name: Install Dependencies - run: yarn install + run: yarn config set ignore-engines true && yarn install - name: Generate Prisma Client and Test run: npx prisma generate && yarn test 2>&1 | tee test-report.txt @@ -33,4 +33,4 @@ jobs: exit 1 else echo "Tests have passed." - fi \ No newline at end of file + fi diff --git a/Dockerfile b/Dockerfile index b2e27f4..095f05e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ -FROM node:18 AS install +FROM node:16 AS install WORKDIR /app COPY package.json ./ +RUN yarn config set ignore-engines true RUN yarn install -FROM node:18 as build +FROM node:16 as build WORKDIR /app COPY prisma ./prisma/ RUN npx prisma generate @@ -13,7 +14,7 @@ COPY --from=install /app/node_modules ./node_modules COPY . . RUN npm run build -FROM node:18 +FROM node:16 WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/package*.json ./ From 28f53c65d0238139f9e38833aff077594fe1f9b9 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Thu, 18 Jul 2024 12:21:31 +0530 Subject: [PATCH 03/11] Enable scheduling of bots. (#244) --- package.json | 2 ++ src/app.module.ts | 2 ++ src/modules/bot/bot.controller.spec.ts | 40 ++++++++++++++++++++++---- src/modules/bot/bot.controller.ts | 10 ++++++- src/modules/bot/bot.service.spec.ts | 37 ++++++++++++++++++++++++ src/modules/bot/bot.service.ts | 14 ++++++++- 6 files changed, 97 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 64245d3..ccd23bc 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@nestjs/passport": "^8.2.1", "@nestjs/platform-express": "^8.0.0", "@nestjs/platform-fastify": "^8.2.6", + "@nestjs/schedule": "^4.1.0", "@nestjs/swagger": "^5.2.0", "@nestjs/terminus": "^9.2.2", "@prisma/client": "3", @@ -50,6 +51,7 @@ "cache-manager-redis-store": "^2.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", + "cron": "^3.1.7", "expect-type": "^0.13.0", "fastify-compress": "3.7.0", "fastify-helmet": "^7.1.0", diff --git a/src/app.module.ts b/src/app.module.ts index ff298ca..134790f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,7 @@ import { HealthModule } from './health/health.module'; import { FusionAuthClientProvider } from './modules/user-segment/fusionauth/fusionauthClientProvider'; import { VaultClientProvider } from './modules/secrets/secrets.service.provider'; import { MonitoringModule } from './monitoring/monitoring.module'; +import { ScheduleModule } from '@nestjs/schedule'; import * as redisStore from 'cache-manager-redis-store'; @@ -58,6 +59,7 @@ import * as redisStore from 'cache-manager-redis-store'; max: 1000 }), MonitoringModule, + ScheduleModule.forRoot(), ], controllers: [AppController, ServiceController], providers: [ diff --git a/src/modules/bot/bot.controller.spec.ts b/src/modules/bot/bot.controller.spec.ts index 69a48f4..afd1022 100644 --- a/src/modules/bot/bot.controller.spec.ts +++ b/src/modules/bot/bot.controller.spec.ts @@ -14,6 +14,7 @@ import { BotStatus, Prisma } from '../../../prisma/generated/prisma-client-js'; import { FusionAuthClientProvider } from '../user-segment/fusionauth/fusionauthClientProvider'; import { BadRequestException, CacheModule, ServiceUnavailableException } from '@nestjs/common'; import { VaultClientProvider } from '../secrets/secrets.service.provider'; +import { SchedulerRegistry } from '@nestjs/schedule'; class MockPrismaService { bot = { @@ -94,6 +95,7 @@ const mockBotService = { getBroadcastReport: jest.fn(), start: jest.fn(), + scheduleNotification: jest.fn(), } const mockBotData: Prisma.BotGetPayload<{ @@ -254,7 +256,8 @@ describe('BotController', () => { BotService, { provide: BotService, useValue: mockBotService, - } + }, + SchedulerRegistry, ], }).compile(); @@ -262,21 +265,25 @@ describe('BotController', () => { configService = module.get(ConfigService); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('bot start returns bad request on non existent bot', async () => { - expect(botController.startOne('testBotIdNotExisting', {})).rejects.toThrowError(new BadRequestException('Bot does not exist')); + expect(botController.startOne('testBotIdNotExisting', '1', {})).rejects.toThrowError(new BadRequestException('Bot does not exist')); }); it('bot start returns bad request when bot does not have user data', async () => { - expect(botController.startOne('noUser', {})).rejects.toThrowError(new BadRequestException('Bot does not contain user segment data')); + expect(botController.startOne('noUser', '1', {})).rejects.toThrowError(new BadRequestException('Bot does not contain user segment data')); }); it('disabled bot returns unavailable error',async () => { - await expect(() => botController.startOne('disabled', {})).rejects.toThrowError(ServiceUnavailableException); + await expect(() => botController.startOne('disabled', '1', {})).rejects.toThrowError(ServiceUnavailableException); }); it('only disabled bot returns unavailable error',async () => { - expect(botController.startOne('pinned', {})).resolves; - expect(botController.startOne('enabled', {})).resolves; + expect(botController.startOne('pinned', '1', {})).resolves; + expect(botController.startOne('enabled', '1', {})).resolves; }); it('update only passes relevant bot data to bot service', async () => { @@ -367,4 +374,25 @@ describe('BotController', () => { expect(resp).toBeTruthy(); updateParametersPassed = []; }); + + it('bot start schedule for future time', async () => { + const futureTime = new Date(Date.now() + 100000).toUTCString(); + await botController.startOne( + 'enabled', + futureTime, + { 'conversation-authorization': 'testToken' } + ); + expect(mockBotService.scheduleNotification).toHaveBeenCalledTimes(1); + expect(mockBotService.start).toHaveBeenCalledTimes(0); + }); + + it('bot start triggers immediately when triggerTime is not passed', async () => { + await botController.startOne( + 'enabled', + undefined, + { 'conversation-authorization': 'testToken' } + ); + expect(mockBotService.scheduleNotification).toHaveBeenCalledTimes(0); + expect(mockBotService.start).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/modules/bot/bot.controller.ts b/src/modules/bot/bot.controller.ts index eb5345f..b1ba7dc 100644 --- a/src/modules/bot/bot.controller.ts +++ b/src/modules/bot/bot.controller.ts @@ -200,7 +200,7 @@ export class BotController { AddOwnerInfoInterceptor, AddROToResponseInterceptor, ) - async startOne(@Param('id') id: string, @Headers() headers) { + async startOne(@Param('id') id: string, @Query('triggerTime') triggerTime: string | undefined, @Headers() headers) { const bot: Prisma.BotGetPayload<{ include: { users: { @@ -228,6 +228,14 @@ export class BotController { if (bot?.status == BotStatus.DISABLED) { throw new ServiceUnavailableException("Bot is not enabled!"); } + if (triggerTime) { + const currentTime = new Date(); + const scheduledTime = new Date(triggerTime); + if (scheduledTime.getTime() > currentTime.getTime()) { + await this.botService.scheduleNotification(id, scheduledTime, bot?.users[0].all?.config, headers['conversation-authorization']); + return; + } + } const res = await this.botService.start(id, bot?.users[0].all?.config, headers['conversation-authorization']); return res; } diff --git a/src/modules/bot/bot.service.spec.ts b/src/modules/bot/bot.service.spec.ts index e5d7f6f..e69b59d 100644 --- a/src/modules/bot/bot.service.spec.ts +++ b/src/modules/bot/bot.service.spec.ts @@ -1,3 +1,13 @@ +const MockCronJob = { + start: jest.fn(), +}; + +jest.mock('cron', () => { + return { + CronJob: jest.fn().mockImplementation(() => MockCronJob), + } +}); + import { Test, TestingModule } from '@nestjs/testing'; import { BotService } from './bot.service'; import { ConfigService } from '@nestjs/config'; @@ -11,6 +21,8 @@ import { BotStatus } from '../../../prisma/generated/prisma-client-js'; import { UserSegmentService } from '../user-segment/user-segment.service'; import { ConversationLogicService } from '../conversation-logic/conversation-logic.service'; import { assert } from 'console'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; const MockPrismaService = { @@ -146,6 +158,10 @@ const mockFile: Express.Multer.File = { }) }; +const MockSchedulerRegistry = { + addCronJob: jest.fn(), +} + const mockBotsDb = [{ "id": "testId", "createdAt": "2023-05-04T19:22:40.768Z", @@ -391,6 +407,10 @@ describe('BotService', () => { }, UserSegmentService, ConversationLogicService, + SchedulerRegistry, { + provide: SchedulerRegistry, + useValue: MockSchedulerRegistry, + }, ], }).compile(); @@ -835,4 +855,21 @@ describe('BotService', () => { } fetchMock.restore(); }); + + it('bot scheduling works as expected', async () => { + const futureDate = new Date(Date.now() + 100000); + jest.spyOn(MockSchedulerRegistry, 'addCronJob').mockImplementation((id: string, cron) => { + expect(id.startsWith('notification_')).toBe(true); + expect(cron).toStrictEqual(MockCronJob); + }); + await botService.scheduleNotification( + 'mockBotId', + futureDate, + { + 'myVar': 'myVal', + }, + 'mockToken', + ); + expect(MockCronJob.start).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index 38c3704..e7d4613 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -17,7 +17,9 @@ import { Cache } from 'cache-manager'; import { DeleteBotsDTO } from './dto/delete-bot-dto'; import { UserSegmentService } from '../user-segment/user-segment.service'; import { ConversationLogicService } from '../conversation-logic/conversation-logic.service'; -import { createHash } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; @Injectable() export class BotService { @@ -28,6 +30,7 @@ export class BotService { private configService: ConfigService, private userSegmentService: UserSegmentService, private conversationLogicService: ConversationLogicService, + private schedulerRegistry: SchedulerRegistry, //@ts-ignore @Inject(CACHE_MANAGER) public cacheManager: Cache, ) { @@ -152,6 +155,15 @@ export class BotService { }); } + // Example Trigger Time: '2021-03-21T00:00:00.000Z' (This is UTC time). + async scheduleNotification(id: string, scheduledTime: Date, config: any, token: string) { + const job = new CronJob(scheduledTime, () => { + this.start(id, config, token); + }); + this.schedulerRegistry.addCronJob(`notification_${randomUUID()}`, job); + job.start(); + } + // dateString = '2020-01-01' private getDateFromString(dateString: string) { return new Date(dateString); From be90edc8dbbe700600a565fcb14aba9c4158fdb1 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Thu, 18 Jul 2024 12:21:45 +0530 Subject: [PATCH 04/11] Added feature to modify notification title and description. (#245) --- src/modules/bot/bot.controller.ts | 12 +++++++ src/modules/bot/bot.service.spec.ts | 23 ++++++++++++ src/modules/bot/bot.service.ts | 50 +++++++++++++++++++++++++-- src/modules/bot/dto/update-bot.dto.ts | 5 +++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/modules/bot/bot.controller.ts b/src/modules/bot/bot.controller.ts index b1ba7dc..5a6d2d7 100644 --- a/src/modules/bot/bot.controller.ts +++ b/src/modules/bot/bot.controller.ts @@ -31,6 +31,7 @@ import { Request } from 'express'; import { extname } from 'path'; import fs from 'fs'; import { DeleteBotsDTO } from './dto/delete-bot-dto'; +import { ModifyNotificationDTO } from './dto/update-bot.dto'; const editFileName = (req: Request, file: Express.Multer.File, callback) => { @@ -380,4 +381,15 @@ export class BotController { } return await this.botService.getBroadcastReport(botId, limit, nextPage); } + + @Post('/modifyNotification/:botId') + @UseInterceptors( + AddResponseObjectInterceptor, + AddAdminHeaderInterceptor, + AddOwnerInfoInterceptor, + AddROToResponseInterceptor, + ) + async modifyNotification(@Param('botId') botId: string, @Body() body: ModifyNotificationDTO) { + await this.botService.modifyNotification(botId, body.title, body.description); + } } diff --git a/src/modules/bot/bot.service.spec.ts b/src/modules/bot/bot.service.spec.ts index e69b59d..ea0a0b7 100644 --- a/src/modules/bot/bot.service.spec.ts +++ b/src/modules/bot/bot.service.spec.ts @@ -117,6 +117,7 @@ class MockConfigService { case 'CAFFINE_INVALIDATE_ENDPOINT': return '/testcaffineendpoint'; case 'AUTHORIZATION_KEY_TRANSACTION_LAYER': return 'testAuthToken'; case 'BROADCAST_BOT_REPORT_ENDPOINT': return 'testBotReportEndpoint'; + case 'ORCHESTRATOR_BASE_URL': return 'http://orchestrator_url'; default: return ''; } } @@ -418,6 +419,10 @@ describe('BotService', () => { configService = module.get(ConfigService); }); + afterEach(async () => { + fetchMock.restore(); + }); + it('create bot test', async () => { fetchMock.postOnce(`${configService.get('MINIO_MEDIA_UPLOAD_URL')}`, { fileName: 'testFileName' @@ -609,6 +614,9 @@ describe('BotService', () => { fetchMock.deleteOnce(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, true ); + fetchMock.deleteOnce(`${configService.get('ORCHESTRATOR_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); await botService.update('testBotIdExisting', { 'status': 'DISABLED' }); @@ -623,6 +631,9 @@ describe('BotService', () => { fetchMock.deleteOnce(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, true ); + fetchMock.deleteOnce(`${configService.get('ORCHESTRATOR_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); await expect(botService.update('testBotIdExisting', { 'endDate': '1129-299-092' })) @@ -653,6 +664,9 @@ describe('BotService', () => { fetchMock.deleteOnce(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, () => { throw new InternalServerErrorException(); }); + fetchMock.deleteOnce(`${configService.get('ORCHESTRATOR_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); await expect(botService.update('testBotIdExisting', { 'endDate': '2023-10-12' })) @@ -695,6 +709,9 @@ describe('BotService', () => { fetchMock.delete(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, true ); + fetchMock.delete(`${configService.get('ORCHESTRATOR_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); await botService.remove({ids: ['testId'], endDate: null}); expect(deletedIds).toEqual( [ @@ -728,6 +745,9 @@ describe('BotService', () => { fetchMock.delete(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, true ); + fetchMock.delete(`${configService.get('ORCHESTRATOR_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); await botService.remove({ids: null, endDate: '2025-12-01'}); expect(deletedIds).toEqual( [ @@ -761,6 +781,9 @@ describe('BotService', () => { fetchMock.delete(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, true ); + fetchMock.deleteOnce(`${configService.get('ORCHESTRATOR_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); const response = await botService.remove({ids: ['testId'], endDate: null}); const expectedBotIds = ['testId']; expect(response).toEqual(expectedBotIds); diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index e7d4613..21b757b 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -619,14 +619,16 @@ export class BotService { async invalidateTransactionLayerCache() { const inbound_base = this.configService.get('UCI_CORE_BASE_URL'); + const orchestrator_base = this.configService.get('ORCHESTRATOR_BASE_URL'); const caffine_invalidate_endpoint = this.configService.get('CAFFINE_INVALIDATE_ENDPOINT'); const transaction_layer_auth_token = this.configService.get('AUTHORIZATION_KEY_TRANSACTION_LAYER'); if (!inbound_base || !caffine_invalidate_endpoint || !transaction_layer_auth_token) { this.logger.error(`Missing configuration: inbound endpoint: ${inbound_base}, caffine reset endpoint: ${caffine_invalidate_endpoint} or transaction layer auth token.`); throw new InternalServerErrorException(); } - const caffine_reset_url = `${inbound_base}${caffine_invalidate_endpoint}`; - return fetch(caffine_reset_url, {method: 'DELETE', headers: {'Authorization': transaction_layer_auth_token}}) + const caffine_reset_url_inbound = `${inbound_base}${caffine_invalidate_endpoint}`; + const caffine_reset_url_orchestrator = `${orchestrator_base}${caffine_invalidate_endpoint}`; + await fetch(caffine_reset_url_inbound, {method: 'DELETE', headers: {'Authorization': transaction_layer_auth_token}}) .then((resp) => { if (resp.ok) { return resp.json(); @@ -637,9 +639,23 @@ export class BotService { }) .then() .catch((err) => { - this.logger.error(`Got failure response from inbound on cache invalidation endpoint ${caffine_reset_url}. Error: ${err}`); + this.logger.error(`Got failure response from inbound on cache invalidation endpoint ${caffine_reset_url_inbound}. Error: ${err}`); throw new ServiceUnavailableException('Could not invalidate cache after update!'); }); + await fetch(caffine_reset_url_orchestrator, {method: 'DELETE', headers: {'Authorization': transaction_layer_auth_token}}) + .then((resp) => { + if (resp.ok) { + return resp.json(); + } + else { + throw new ServiceUnavailableException(resp); + } + }) + .then() + .catch((err) => { + this.logger.error(`Got failure response from inbound on cache invalidation endpoint ${caffine_reset_url_orchestrator}. Error: ${err}`); + throw new ServiceUnavailableException('Could not invalidate cache after update!'); + }) } async remove(deleteBotsDTO: DeleteBotsDTO) { @@ -775,4 +791,32 @@ export class BotService { throw new ServiceUnavailableException('Could not pull data from database!'); }); } + + async modifyNotification(botId: string, title?: string, description?: string) { + const requiredBot = await this.findOne(botId); + if (!botId) { + throw new BadRequestException(`Bot with id: ${botId} does not exist!`); + } + const requiredTransformer = requiredBot?.logicIDs?.[0]?.transformers?.[0]; + if (!requiredTransformer) { + throw new BadRequestException(`Bad configuration! Bot ${botId} does not contain transformer config.`); + } + const meta = requiredTransformer.meta!; + if (title) { + meta['title'] = title; + } + if (description) { + meta['body'] = description; + } + await this.prisma.transformerConfig.update({ + where: { + id: requiredTransformer.id, + }, + data: { + meta: meta, + }, + }); + await this.cacheManager.reset(); + await this.invalidateTransactionLayerCache(); + } } diff --git a/src/modules/bot/dto/update-bot.dto.ts b/src/modules/bot/dto/update-bot.dto.ts index f79f175..54f2e92 100644 --- a/src/modules/bot/dto/update-bot.dto.ts +++ b/src/modules/bot/dto/update-bot.dto.ts @@ -2,3 +2,8 @@ import { PartialType } from '@nestjs/swagger'; import { CreateBotDto } from './create-bot.dto'; export class UpdateBotDto extends PartialType(CreateBotDto) {} + +export type ModifyNotificationDTO = { + title?: string, + description?: string, +} From 25b2de703c16d5a7c2f2b8947f14506b16f66871 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Thu, 18 Jul 2024 15:04:21 +0530 Subject: [PATCH 05/11] Add test cases for notification modify endpoint. (#247) --- src/modules/bot/bot.service.spec.ts | 35 ++++++++++++++++++++++++++++- src/modules/bot/bot.service.ts | 2 +- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/modules/bot/bot.service.spec.ts b/src/modules/bot/bot.service.spec.ts index ea0a0b7..51c2f71 100644 --- a/src/modules/bot/bot.service.spec.ts +++ b/src/modules/bot/bot.service.spec.ts @@ -89,7 +89,8 @@ const MockPrismaService = { transformerConfig: { deleteMany: (filter) => { deletedIds.push({'transformerConfig': filter.where.id.in}); - } + }, + update: jest.fn(), }, conversationLogic: { deleteMany: (filter) => { @@ -879,6 +880,38 @@ describe('BotService', () => { fetchMock.restore(); }); + it('modifyNotification works as expected', async () => { + fetchMock.getOnce(`${configService.get('MINIO_GET_SIGNED_FILE_URL')}/?fileName=testImageFile`, + 'testImageUrl' + ); + fetchMock.deleteOnce(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); + fetchMock.deleteOnce(`${configService.get('ORCHESTRATOR_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); + jest.spyOn(MockPrismaService.transformerConfig, 'update').mockImplementation((filter) => { + const botCopy = JSON.parse(JSON.stringify(mockBotsDb[0])); + botCopy.logicIDs[0].transformers[0].meta.body = 'myDescription'; + botCopy.logicIDs[0].transformers[0].meta.title = 'myTitle'; + expect(filter).toStrictEqual({ + where: { + id: 'testTransformerId', + }, + data: { + meta: botCopy.logicIDs[0].transformers[0].meta, + }, + }); + }); + await botService.modifyNotification('existingBot', 'myTitle', 'myDescription'); + }); + + it ('modifyNotification throws for non existing bot', () => { + expect(botService.modifyNotification('testBotIdNotExisting', 'myTitle', 'myDescription')) + .rejects + .toThrowError(new BadRequestException(`Bot with id: testBotIdNotExisting does not exist!`)); + }); + it('bot scheduling works as expected', async () => { const futureDate = new Date(Date.now() + 100000); jest.spyOn(MockSchedulerRegistry, 'addCronJob').mockImplementation((id: string, cron) => { diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index 21b757b..bee269e 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -794,7 +794,7 @@ export class BotService { async modifyNotification(botId: string, title?: string, description?: string) { const requiredBot = await this.findOne(botId); - if (!botId) { + if (!requiredBot) { throw new BadRequestException(`Bot with id: ${botId} does not exist!`); } const requiredTransformer = requiredBot?.logicIDs?.[0]?.transformers?.[0]; From b5b7fda481878fc58e87f592b01e2bbc32a7aa15 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Thu, 18 Jul 2024 15:48:56 +0530 Subject: [PATCH 06/11] Enable persistent notification scheduling. (#246) --- .../migration.sql | 14 ++++++ .../migrations/20240718084125_/migration.sql | 2 + prisma/schema.prisma | 9 ++++ src/modules/bot/bot.service.spec.ts | 15 ++++++ src/modules/bot/bot.service.ts | 46 +++++++++++++++++-- 5 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20240718081636_add_schedule_table/migration.sql create mode 100644 prisma/migrations/20240718084125_/migration.sql diff --git a/prisma/migrations/20240718081636_add_schedule_table/migration.sql b/prisma/migrations/20240718081636_add_schedule_table/migration.sql new file mode 100644 index 0000000..5471074 --- /dev/null +++ b/prisma/migrations/20240718081636_add_schedule_table/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "Schedules" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "scheduledAt" TIMESTAMP(3) NOT NULL, + "authToken" TEXT NOT NULL, + "botId" TEXT NOT NULL, + "config" JSONB NOT NULL, + + CONSTRAINT "Schedules_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Schedules_botId_key" ON "Schedules"("botId"); diff --git a/prisma/migrations/20240718084125_/migration.sql b/prisma/migrations/20240718084125_/migration.sql new file mode 100644 index 0000000..eb68f42 --- /dev/null +++ b/prisma/migrations/20240718084125_/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "Schedules_botId_key"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c062d53..b9f28ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -132,3 +132,12 @@ model ConversationLogic { bots Bot[] transformers TransformerConfig[] } + +model Schedules { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid() + createdAt DateTime @default(now()) + scheduledAt DateTime + authToken String + botId String + config Json +} diff --git a/src/modules/bot/bot.service.spec.ts b/src/modules/bot/bot.service.spec.ts index 51c2f71..abd598e 100644 --- a/src/modules/bot/bot.service.spec.ts +++ b/src/modules/bot/bot.service.spec.ts @@ -105,6 +105,9 @@ const MockPrismaService = { } }, }, + schedules: { + upsert: jest.fn(), + } } class MockConfigService { @@ -918,6 +921,17 @@ describe('BotService', () => { expect(id.startsWith('notification_')).toBe(true); expect(cron).toStrictEqual(MockCronJob); }); + jest.spyOn(MockPrismaService.schedules, 'upsert').mockImplementation((filter) => { + expect(filter.where.id).toBeDefined(); + expect(filter.create).toStrictEqual({ + authToken: 'mockToken', + botId: 'mockBotId', + scheduledAt: futureDate, + config: { + 'myVar': 'myVal', + }, + }); + }); await botService.scheduleNotification( 'mockBotId', futureDate, @@ -927,5 +941,6 @@ describe('BotService', () => { 'mockToken', ); expect(MockCronJob.start).toHaveBeenCalledTimes(1); + expect(MockPrismaService.schedules.upsert).toHaveBeenCalledTimes(1); }); }); diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index bee269e..fdf14a3 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger, Inject,CACHE_MANAGER, ServiceUnavailableException, NotFoundException, BadRequestException} from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger, Inject,CACHE_MANAGER, ServiceUnavailableException, NotFoundException, BadRequestException, OnModuleInit} from '@nestjs/common'; import { Bot, BotStatus, @@ -22,7 +22,7 @@ import { SchedulerRegistry } from '@nestjs/schedule'; import { CronJob } from 'cron'; @Injectable() -export class BotService { +export class BotService implements OnModuleInit { private readonly logger: Logger; constructor( @@ -37,6 +37,27 @@ export class BotService { this.logger = new Logger(BotService.name); } + // Reschedule all the notifications, if service restarted. + async onModuleInit() { + const pendingSchedules = await this.prisma.schedules.findMany({ + where: { + scheduledAt: { + gte: new Date(), + } + } + }); + this.logger.log(`Found ${pendingSchedules.length} pending schedules.`); + pendingSchedules.forEach((schedule) => { + this.scheduleNotification( + schedule.botId, + schedule.scheduledAt, + schedule.config, + schedule.authToken, + schedule.id, + ); + }); + } + private include = { users: { include: { @@ -156,12 +177,29 @@ export class BotService { } // Example Trigger Time: '2021-03-21T00:00:00.000Z' (This is UTC time). - async scheduleNotification(id: string, scheduledTime: Date, config: any, token: string) { + // Note: A scheduled notification `config`(url, per page, etc.) will not be modified + // after scheduling even if the actual bot data is modified. Although, the `title` and + // `description` "will" change if the data is modified. + async scheduleNotification(botId: string, scheduledTime: Date, config: any, token: string, id?: string) { + if (!id) id = randomUUID(); + await this.prisma.schedules.upsert({ + where: { + id: id, + }, + update: {}, + create: { + authToken: token, + botId: botId, + scheduledAt: scheduledTime, + config: config, + } + }); const job = new CronJob(scheduledTime, () => { - this.start(id, config, token); + this.start(botId, config, token); }); this.schedulerRegistry.addCronJob(`notification_${randomUUID()}`, job); job.start(); + this.logger.log(`Scheduled notification for: ${botId}, at: ${scheduledTime.toDateString()}`); } // dateString = '2020-01-01' From 60d8f2bd79c3070e6c37756642f4fc470eba665e Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Thu, 1 Aug 2024 15:10:30 +0530 Subject: [PATCH 07/11] Add schedule data to bot fetch api. (#248) --- .../migration.sql | 12 ++++++++++++ prisma/schema.prisma | 8 +++++--- src/modules/bot/bot.service.ts | 4 ++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20240801091425_add_foriegn_key_to_bot_in_schedule/migration.sql diff --git a/prisma/migrations/20240801091425_add_foriegn_key_to_bot_in_schedule/migration.sql b/prisma/migrations/20240801091425_add_foriegn_key_to_bot_in_schedule/migration.sql new file mode 100644 index 0000000..adb780c --- /dev/null +++ b/prisma/migrations/20240801091425_add_foriegn_key_to_bot_in_schedule/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Changed the type of `botId` on the `Schedules` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- AlterTable +ALTER TABLE "Schedules" DROP COLUMN "botId", +ADD COLUMN "botId" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "Schedules" ADD CONSTRAINT "Schedules_botId_fkey" FOREIGN KEY ("botId") REFERENCES "Bot"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b9f28ed..dcfc2bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,6 +95,7 @@ model Bot { tags String[] botImage String? meta Json? + schedules Schedules[] } model UserSegment { @@ -134,10 +135,11 @@ model ConversationLogic { } model Schedules { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid() - createdAt DateTime @default(now()) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid() + createdAt DateTime @default(now()) scheduledAt DateTime authToken String - botId String + bot Bot @relation(fields: [botId], references: [id]) config Json + botId String @db.Uuid } diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index fdf14a3..e8a7ddc 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -70,6 +70,7 @@ export class BotService implements OnModuleInit { adapter: true, }, }, + schedules: {}, }; pause(id: string) { @@ -200,6 +201,7 @@ export class BotService implements OnModuleInit { this.schedulerRegistry.addCronJob(`notification_${randomUUID()}`, job); job.start(); this.logger.log(`Scheduled notification for: ${botId}, at: ${scheduledTime.toDateString()}`); + await this.cacheManager.reset(); } // dateString = '2020-01-01' @@ -349,6 +351,7 @@ export class BotService implements OnModuleInit { adapter: true; }; }; + schedules: {}, }; }>[]> { const startTime = performance.now(); @@ -446,6 +449,7 @@ export class BotService implements OnModuleInit { adapter: true; }; }; + schedules: {}, }; }> | null> { const startTime = performance.now(); From 8e79dd388d389262155423f0e4b491afd99bc8d7 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Fri, 2 Aug 2024 10:25:14 +0530 Subject: [PATCH 08/11] Change startDate and endDate to timestamp. (#249) --- .../migration.sql | 3 +++ prisma/schema.prisma | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20240802044019_change_start_end_date_to_timestamp/migration.sql diff --git a/prisma/migrations/20240802044019_change_start_end_date_to_timestamp/migration.sql b/prisma/migrations/20240802044019_change_start_end_date_to_timestamp/migration.sql new file mode 100644 index 0000000..79c16a3 --- /dev/null +++ b/prisma/migrations/20240802044019_change_start_end_date_to_timestamp/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Bot" ALTER COLUMN "startDate" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "endDate" SET DATA TYPE TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dcfc2bd..7dd73ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,8 +89,8 @@ model Bot { ownerOrgID String? purpose String? description String? - startDate DateTime? @db.Date - endDate DateTime? @db.Date + startDate DateTime? + endDate DateTime? status BotStatus @default(DRAFT) tags String[] botImage String? From d7a510e0daf047f615c8bd604460ee5d4317940a Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Fri, 2 Aug 2024 16:26:23 +0530 Subject: [PATCH 09/11] Enable deletion of schedules. (#250) --- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + src/modules/bot/bot.controller.ts | 11 ++++++ src/modules/bot/bot.service.ts | 35 +++++++++++++++---- 4 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20240802101405_add_name_in_schedule_table/migration.sql diff --git a/prisma/migrations/20240802101405_add_name_in_schedule_table/migration.sql b/prisma/migrations/20240802101405_add_name_in_schedule_table/migration.sql new file mode 100644 index 0000000..c975482 --- /dev/null +++ b/prisma/migrations/20240802101405_add_name_in_schedule_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Schedules" ADD COLUMN "name" TEXT NOT NULL DEFAULT E''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7dd73ee..c1eca4b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,6 +136,7 @@ model ConversationLogic { model Schedules { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid() + name String @default("") createdAt DateTime @default(now()) scheduledAt DateTime authToken String diff --git a/src/modules/bot/bot.controller.ts b/src/modules/bot/bot.controller.ts index 5a6d2d7..01f1973 100644 --- a/src/modules/bot/bot.controller.ts +++ b/src/modules/bot/bot.controller.ts @@ -241,6 +241,17 @@ export class BotController { return res; } + @Delete('/schedule/:id') + @UseInterceptors( + AddResponseObjectInterceptor, + AddAdminHeaderInterceptor, + AddOwnerInfoInterceptor, + AddROToResponseInterceptor, + ) + async deleteSchedule(@Param('id') id: string) { + await this.botService.deleteSchedule(id); + } + @Get('/:id/addUser/:userId') @UseInterceptors( AddResponseObjectInterceptor, diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index e8a7ddc..409e892 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -183,6 +183,12 @@ export class BotService implements OnModuleInit { // `description` "will" change if the data is modified. async scheduleNotification(botId: string, scheduledTime: Date, config: any, token: string, id?: string) { if (!id) id = randomUUID(); + const job = new CronJob(scheduledTime, () => { + this.start(botId, config, token); + }); + const scheduleName = `notification_${randomUUID()}`; + this.schedulerRegistry.addCronJob(scheduleName, job); + job.start(); await this.prisma.schedules.upsert({ where: { id: id, @@ -190,20 +196,37 @@ export class BotService implements OnModuleInit { update: {}, create: { authToken: token, + name: scheduleName, botId: botId, scheduledAt: scheduledTime, config: config, } }); - const job = new CronJob(scheduledTime, () => { - this.start(botId, config, token); - }); - this.schedulerRegistry.addCronJob(`notification_${randomUUID()}`, job); - job.start(); - this.logger.log(`Scheduled notification for: ${botId}, at: ${scheduledTime.toDateString()}`); + this.logger.log(`Scheduled notification for: ${botId}, at: ${scheduledTime.toDateString()}, name: ${scheduleName}`); await this.cacheManager.reset(); } + async deleteSchedule(scheduleId: string) { + if (!scheduleId) { + throw new BadRequestException(`Schedule id is required!`); + } + const existingSchedule = await this.prisma.schedules.findUnique({ + where: { + id: scheduleId, + } + }); + if (!existingSchedule) { + throw new BadRequestException('Schedule does not exist!'); + } + await this.prisma.schedules.delete({ + where: { + id: scheduleId, + } + }); + this.schedulerRegistry.deleteCronJob(existingSchedule.name); + this.logger.log(`Deleted schedule for bot: ${existingSchedule.botId}`); + } + // dateString = '2020-01-01' private getDateFromString(dateString: string) { return new Date(dateString); From 42b9d70656e1836f3634de5714343a8164b64328 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Fri, 2 Aug 2024 16:39:16 +0530 Subject: [PATCH 10/11] Fix scheduling test. (#251) --- src/modules/bot/bot.service.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/bot/bot.service.spec.ts b/src/modules/bot/bot.service.spec.ts index abd598e..a478dff 100644 --- a/src/modules/bot/bot.service.spec.ts +++ b/src/modules/bot/bot.service.spec.ts @@ -923,6 +923,8 @@ describe('BotService', () => { }); jest.spyOn(MockPrismaService.schedules, 'upsert').mockImplementation((filter) => { expect(filter.where.id).toBeDefined(); + expect(filter.create.name).toBeDefined(); + delete filter.create.name; expect(filter.create).toStrictEqual({ authToken: 'mockToken', botId: 'mockBotId', From 14a067db5ecb61617463540b9ed0a58caa46d1d0 Mon Sep 17 00:00:00 2001 From: Chinmoy Date: Mon, 5 Aug 2024 11:03:18 +0530 Subject: [PATCH 11/11] Reset cache on deleted schedule. (#252) --- src/modules/bot/bot.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index 409e892..ff68d41 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -224,6 +224,7 @@ export class BotService implements OnModuleInit { } }); this.schedulerRegistry.deleteCronJob(existingSchedule.name); + await this.cacheManager.reset(); this.logger.log(`Deleted schedule for bot: ${existingSchedule.botId}`); }