diff --git a/.node-xmlhttprequest-sync-3980100 b/.node-xmlhttprequest-sync-3980100 deleted file mode 100644 index e69de29..0000000 diff --git a/package.json b/package.json index 616f679..64245d3 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", + "socket.io-client": "^4.7.2", "swagger-ui-express": "^4.3.0", "undici": "^5.0.0", "uuid": "^8.3.2", diff --git a/src/health/health.service.ts b/src/health/health.service.ts index a2608f5..6deabb3 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -10,6 +10,7 @@ import { } from '@nestjs/terminus'; import { PrismaService } from '../global-services/prisma.service'; import { FormService } from '../modules/form/form.service'; +import { io } from 'socket.io-client'; @Injectable() export class HealthService extends HealthIndicator{ @@ -28,6 +29,7 @@ export class HealthService extends HealthIndicator{ () => this.checkUciCoreHealth(), () => this.checkDatabaseHealth(), () => this.checkFormServiceHealth(), + () => this.checkTransportSocketHealth(), ]); } @@ -53,4 +55,104 @@ export class HealthService extends HealthIndicator{ throw new HealthCheckError("FormService failed to connect!", this.getStatus('FormService', false, {message: e.message})); }); } + + async checkTransportSocketHealth(): Promise { + const baseUrl = this.configService.get('SOCKET_URL'); + const connOptions = { + transportOptions: { + polling: { + extraHeaders: { + Authorization: `Bearer ${this.configService.get( + 'SOCKET_AUTH_TOKEN', + )}`, + channel: this.configService.get('SOCKET_CONNECTION_CHANNEL'), + }, + }, + }, + query: { + deviceId: this.configService.get('SOCKET_TO'), + }, + autoConnect: false, + }; + + const payload: any = { + content: { + text: '*', + appId: this.configService.get('SOCKET_APP_ID'), + channel: this.configService.get('SOCKET_CHANNEL'), + context: null, + accessToken: null, + }, + to: this.configService.get('SOCKET_TO'), + }; + try { + const socket = await this.connectSocket(baseUrl, connOptions); + if (!socket) { + return new HealthCheckError( + 'Socket connection timed out', + this.getStatus('TransportSocketService', false, { + message: 'Socket connection timed out', + }), + ); + } + + const responseReceived = await this.sendBotRequest(socket, payload); + + if (responseReceived) { + socket.disconnect(); + return this.getStatus('TransportSocketService', true); + } else { + return new HealthCheckError( + 'Bot response timed out', + this.getStatus('TransportSocketService', false, { + message: 'Bot response timed out', + }), + ); + } + } catch (error) { + return new HealthCheckError( + 'An error occurred', + this.getStatus('TransportSocketService', false, { + message: 'An error occurred', + }), + ); + } + } + + private async connectSocket(baseUrl: string, connOptions: any): Promise { + return new Promise(async (resolve) => { + const socket = await io(baseUrl, connOptions); + + socket.connect(); + socket.on('connect', function () { + resolve(socket); + }); + socket.on('connect_error', () => { + resolve(false); + }); + setTimeout(async () => { + resolve(false); + }, this.configService.get('SOCKET_TIMEOUT_TIME') || 20000); + }); + } + + private async sendBotRequest(socket: any, payload: any): Promise { + const newPayload = { ...payload }; + return new Promise(async (resolve) => { + socket.on('session', async (session) => { + const socketID = session.socketID; + const userID = session.userID; + newPayload.content.from = socketID; + newPayload.content.userId = userID; + socket.emit('botRequest', newPayload); + }); + + socket.on('botResponse', (data) => { + resolve(true); + }); + setTimeout(() => { + resolve(false); + }, this.configService.get('SOCKET_TIMEOUT_TIME') || 20000); // Wait for 20 seconds for bot response + }); + } } diff --git a/src/modules/bot/bot.controller.ts b/src/modules/bot/bot.controller.ts index 1acd40b..431c8b6 100644 --- a/src/modules/bot/bot.controller.ts +++ b/src/modules/bot/bot.controller.ts @@ -30,6 +30,7 @@ import { diskStorage } from 'multer'; import { Request } from 'express'; import { extname } from 'path'; import fs from 'fs'; +import { DeleteBotsDTO } from './dto/delete-bot-dto'; const editFileName = (req: Request, file: Express.Multer.File, callback) => { @@ -335,15 +336,26 @@ export class BotController { return this.botService.update(id, updateBotDto); } - @Delete(':id') + @Delete() @UseInterceptors( AddResponseObjectInterceptor, AddAdminHeaderInterceptor, AddOwnerInfoInterceptor, AddROToResponseInterceptor, ) - remove(@Param('id') id: string) { - return this.botService.remove(id); + async remove(@Body() body: DeleteBotsDTO) { + return await this.botService.remove(body); + } + + @Delete(':botId') + @UseInterceptors( + AddResponseObjectInterceptor, + AddAdminHeaderInterceptor, + AddOwnerInfoInterceptor, + AddROToResponseInterceptor, + ) + async removeOne(@Param('botId') botId: string) { + return await this.botService.removeOne(botId); } @Get(':botId/broadcastReport') diff --git a/src/modules/bot/bot.service.spec.ts b/src/modules/bot/bot.service.spec.ts index 9d3372e..72803b8 100644 --- a/src/modules/bot/bot.service.spec.ts +++ b/src/modules/bot/bot.service.spec.ts @@ -44,8 +44,31 @@ const MockPrismaService = { } }, count: () => 10, - update: jest.fn() - } + update: jest.fn(), + deleteMany: (filter) => { + deletedIds.push({'bot': filter.where.id.in}); + } + }, + service: { + deleteMany: (filter) => { + deletedIds.push({'service': filter.where.id.in}); + } + }, + userSegment: { + deleteMany: (filter) => { + deletedIds.push({'userSegment': filter.where.id.in}); + } + }, + transformerConfig: { + deleteMany: (filter) => { + deletedIds.push({'transformerConfig': filter.where.id.in}); + } + }, + conversationLogic: { + deleteMany: (filter) => { + deletedIds.push({'conversationLogic': filter.where.id.in}); + } + }, } class MockConfigService { @@ -320,6 +343,9 @@ const mockConfig = { "totalRecords": 1 }; +// Used for delete bot testing +let deletedIds: any[] = [] + describe('BotService', () => { let botService: BotService; let configService: ConfigService; @@ -367,6 +393,17 @@ describe('BotService', () => { .toThrowError(new ConflictException("Bot already exists with the following name or starting message!")); }); + it('create bot trims bot name properly', async () => { + const mockCreateBotDtoCopy: CreateBotDto & { ownerID: string; ownerOrgID: string } = JSON.parse(JSON.stringify(mockCreateBotDto)); + mockCreateBotDtoCopy.name = ' testBotExistingName '; + expect(botService.create(mockCreateBotDtoCopy, mockFile)).rejects + .toThrowError(new ConflictException("Bot already exists with the following name or starting message!")); + const mockCreateBotDtoCopy2: CreateBotDto & { ownerID: string; ownerOrgID: string } = JSON.parse(JSON.stringify(mockCreateBotDto)); + mockCreateBotDtoCopy2.startingMessage = ' testBotExistingStartingMessage'; + expect(botService.create(mockCreateBotDtoCopy2, mockFile)).rejects + .toThrowError(new ConflictException("Bot already exists with the following name or starting message!")); + }); + it('get bot all data test', async () => { fetchMock.getOnce(`${configService.get('MINIO_GET_SIGNED_FILE_URL')}?fileName=testImageFile`, 'testImageUrl' @@ -506,7 +543,7 @@ describe('BotService', () => { }); it('bot update throws NotFoundException when non existent bot is updated',async () => { - fetchMock.getOnce(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + fetchMock.deleteOnce(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, true ); expect(botService.update('testBotIdNotExisting', { @@ -605,4 +642,97 @@ describe('BotService', () => { ).toBe(true); fetchMock.restore(); }); + + it('bot delete with bot id list works as expected', async () => { + fetchMock.delete(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); + mockBotsDb[0].status = BotStatus.DISABLED; + await botService.remove({ids: ['testId'], endDate: null}); + expect(deletedIds).toEqual( + [ + {'service': ['testId']}, + {'userSegment': ['testUserId']}, + {'transformerConfig': ['testTransformerId']}, + {'conversationLogic': ['testLogicId']}, + {'bot': ['testId']}, + ] + ); + deletedIds = []; + await botService.remove({ids: ['nonExisting'], endDate: null}); + expect(deletedIds).toEqual( + [ + {'service': []}, + {'userSegment': []}, + {'transformerConfig': []}, + {'conversationLogic': []}, + {'bot': []}, + ] + ); + deletedIds = []; + expect(fetchMock.called( + `${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}` + )) + .toBe(true); + mockBotsDb[0].status = BotStatus.ENABLED; + fetchMock.restore(); + }); + + it('bot delete with endDate works as expected', async () => { + fetchMock.delete(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); + mockBotsDb[0].status = BotStatus.DISABLED; + await botService.remove({ids: null, endDate: '2025-12-01'}); + expect(deletedIds).toEqual( + [ + {'service': ['testId']}, + {'userSegment': ['testUserId']}, + {'transformerConfig': ['testTransformerId']}, + {'conversationLogic': ['testLogicId']}, + {'bot': ['testId']}, + ] + ); + deletedIds = []; + await botService.remove({ids: null, endDate: '2023-12-01'}); + expect(deletedIds).toEqual( + [ + {'service': []}, + {'userSegment': []}, + {'transformerConfig': []}, + {'conversationLogic': []}, + {'bot': []}, + ] + ); + expect(fetchMock.called( + `${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}` + )) + .toBe(true); + deletedIds = []; + mockBotsDb[0].status = BotStatus.ENABLED; + fetchMock.restore(); + }); + + it('bot delete only deletes disabled bots', async () => { + fetchMock.delete(`${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}`, + true + ); + mockBotsDb[0].status = BotStatus.ENABLED; + await botService.remove({ids: ['testId'], endDate: null}); + expect(deletedIds).toEqual( + [ + {'service': []}, + {'userSegment': []}, + {'transformerConfig': []}, + {'conversationLogic': []}, + {'bot': []}, + ] + ); + expect(fetchMock.called( + `${configService.get('UCI_CORE_BASE_URL')}${configService.get('CAFFINE_INVALIDATE_ENDPOINT')}` + )) + .toBe(true); + deletedIds = []; + fetchMock.restore(); + }); }); diff --git a/src/modules/bot/bot.service.ts b/src/modules/bot/bot.service.ts index bbe92f5..36b9375 100644 --- a/src/modules/bot/bot.service.ts +++ b/src/modules/bot/bot.service.ts @@ -14,6 +14,7 @@ const limit = pLimit(1); import fs from 'fs'; import FormData from 'form-data'; import { Cache } from 'cache-manager'; +import { DeleteBotsDTO } from './dto/delete-bot-dto'; @Injectable() export class BotService { @@ -158,8 +159,11 @@ export class BotService { const startTime = performance.now(); this.logger.log(`BotService::create: Called with bot name ${data.name}.`); // Check for unique name - const name = data.name; - const startingMessage = data.startingMessage; + if (!data.name || !data.startingMessage) { + throw new BadRequestException('Bot name is required!'); + } + const name = data.name.trim(); + const startingMessage = data.startingMessage.trim(); const alreadyExists = await this.prisma.bot.findFirst({ where: { OR: [ @@ -237,7 +241,7 @@ export class BotService { } } - async findAllUnresolved(): Promise[] | null>> { + }>[]> { const startTime = performance.now(); const cacheKey = `unresolved_bots_data`; const cachedBots = await this.cacheManager.get(cacheKey); @@ -503,14 +507,6 @@ export class BotService { } async update(id: string, updateBotDto: any) { - const inbound_base = this.configService.get('UCI_CORE_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 endpoint: ${caffine_invalidate_endpoint} or transaction layer auth token.`); - throw new InternalServerErrorException(); - } - const caffine_reset_url = `${inbound_base}${caffine_invalidate_endpoint}`; const existingBot = await this.findOne(id); if (!existingBot) { throw new NotFoundException("Bot does not exist!") @@ -547,7 +543,20 @@ export class BotService { data: updateBotDto, }); await this.cacheManager.reset(); - await fetch(caffine_reset_url, {method: 'DELETE', headers: {'Authorization': transaction_layer_auth_token}}) + await this.invalidateTransactionLayerCache(); + return updatedBot; + } + + async invalidateTransactionLayerCache() { + const inbound_base = this.configService.get('UCI_CORE_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}}) .then((resp) => { if (resp.ok) { return resp.json(); @@ -561,11 +570,108 @@ export class BotService { this.logger.error(`Got failure response from inbound on cache invalidation endpoint ${caffine_reset_url}. Error: ${err}`); throw new ServiceUnavailableException('Could not invalidate cache after update!'); }); - return updatedBot; } - remove(id: string) { - return `This action removes a #${id} adapter`; + async remove(deleteBotsDTO: DeleteBotsDTO) { + let botIds = new Set(); + if (deleteBotsDTO.ids) { + botIds = new Set(deleteBotsDTO.ids); + } + const endDate = deleteBotsDTO.endDate; + if ((!botIds || botIds.size == 0) && !endDate) { + throw new BadRequestException('Bot ids or endDate need to be provided!'); + } + let parsedEndDate: Date; + if (endDate) { + const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(endDate)) { + throw new BadRequestException(`Bad date format! Please provide date in 'yyyy-mm-dd' format.`) + } + try { + parsedEndDate = new Date(endDate); + } + catch (err) { + throw new BadRequestException(`Invalid date! Please enter a valid date.`) + } + } + + const allBots = await this.findAllUnresolved(); + const requiredBotIds: string[] = [], requiredServiceIds: string[] = [], + requiredUserIds: string[] = [], requiredLogicIds: string[] = [], + requiredTransformerConfigIds: string[] = []; + allBots.forEach(bot => { + if (bot.status == BotStatus.DISABLED) { + const currentParsedEndDate = new Date(bot.endDate!); + if ( + (botIds.has(bot.id) && !endDate) || + (endDate && (parsedEndDate.getTime() >= currentParsedEndDate.getTime()) && botIds.size == 0) || + (botIds.has(bot.id) && (endDate && (parsedEndDate.getTime() >= currentParsedEndDate.getTime()))) + ) { + requiredBotIds.push(bot.id); + if (bot.logicIDs.length > 0) { + requiredLogicIds.push(bot.logicIDs[0].id); + if (bot.logicIDs[0].transformers.length > 0) { + requiredTransformerConfigIds.push(bot.logicIDs[0].transformers[0].id); + } + } + if (bot.users.length > 0) { + requiredUserIds.push(bot.users[0].id); + if (bot.users[0].all != null) { + requiredServiceIds.push(bot.users[0].all.id); + } + } + } + } + }); + const deletePromises = [ + this.prisma.service.deleteMany({ + where: { + id: { + in: requiredServiceIds, + } + } + }), + this.prisma.userSegment.deleteMany({ + where: { + id: { + in: requiredUserIds, + } + } + }), + this.prisma.transformerConfig.deleteMany({ + where: { + id: { + in: requiredTransformerConfigIds, + } + } + }), + this.prisma.conversationLogic.deleteMany({ + where: { + id: { + in: requiredLogicIds, + } + } + }), + this.prisma.bot.deleteMany({ + where: { + id: { + in: requiredBotIds, + } + } + }), + ]; + + return Promise.all(deletePromises) + .then(() => { + return this.invalidateTransactionLayerCache(); + }) + .catch((err) => { + throw err; + }); + } + + async removeOne(botId: string) { + return this.remove({ids: [botId], endDate: null}); } async getBroadcastReport(botId: string, limit: number, nextPage: string) { diff --git a/src/modules/bot/dto/delete-bot-dto.ts b/src/modules/bot/dto/delete-bot-dto.ts new file mode 100644 index 0000000..8a1eaae --- /dev/null +++ b/src/modules/bot/dto/delete-bot-dto.ts @@ -0,0 +1,4 @@ +export class DeleteBotsDTO { + ids: string[] | undefined | null; + endDate: string | undefined | null; +}