From fb10dddce921049f4a8a1a4bb6f49970e0262be8 Mon Sep 17 00:00:00 2001 From: Rishabh jain Date: Fri, 11 Aug 2023 19:13:22 +0530 Subject: [PATCH] Feat(Link-expiry): Added link expiry using redis (#83) * Feat(Link-expiry): Added link expiry using redis * Added redis layer and integrated RMQ * link-expiry : click-count & resolveRedirect * fix: Sync redis with db * fix: regex test * Fix: Redis integration in Update enpoint * fix: null key in redis & expired link re-caching * fix : input paramater in updateClicksInPostgresDB * fix : depreceated scheduler for clicks updates * Fix : update route , scheduler, createdAt * Chores : Removed unnecessary comments --- apps/api/src/app/app.controller.spec.ts | 4 +- apps/api/src/app/app.controller.ts | 41 ++- apps/api/src/app/app.interface.ts | 4 + apps/api/src/app/app.module.ts | 3 +- apps/api/src/app/app.service.spec.ts | 2 + apps/api/src/app/app.service.ts | 241 ++++++++++++++---- .../addROToResponseInterceptor.ts | 2 +- apps/api/src/app/prisma/schema.prisma | 1 + apps/api/src/app/prisma/seed.ts | 6 +- .../app/scheduler/scheduler.service.spec.ts | 3 +- .../src/app/scheduler/scheduler.service.ts | 20 +- apps/api/src/app/utils/redis.utils.ts | 104 ++++++++ apps/api/src/main.ts | 22 +- 13 files changed, 350 insertions(+), 103 deletions(-) create mode 100644 apps/api/src/app/utils/redis.utils.ts diff --git a/apps/api/src/app/app.controller.spec.ts b/apps/api/src/app/app.controller.spec.ts index 66730e8c..355cb101 100644 --- a/apps/api/src/app/app.controller.spec.ts +++ b/apps/api/src/app/app.controller.spec.ts @@ -15,6 +15,7 @@ import { RedisModule } from '@liaoliaots/nestjs-redis'; import { HttpModule } from '@nestjs/axios'; import { RedisHealthModule } from '@liaoliaots/nestjs-redis/health'; import { PrismaHealthIndicator } from './prisma/prisma.health'; +import { RedisUtils } from './utils/redis.utils'; describe('AppController', () => { let controller: AppController; @@ -91,7 +92,8 @@ describe('AppController', () => { PrismaService, RouterService, TelemetryService, - PrismaHealthIndicator + PrismaHealthIndicator, + RedisUtils ], }) .overrideProvider(RedisService) diff --git a/apps/api/src/app/app.controller.ts b/apps/api/src/app/app.controller.ts index 2167d75b..1490f601 100644 --- a/apps/api/src/app/app.controller.ts +++ b/apps/api/src/app/app.controller.ts @@ -28,7 +28,7 @@ import { Link } from './app.interface'; import { AppService } from './app.service'; import { RouterService } from './router/router.service'; -import { link as LinkModel } from '@prisma/client'; +import { link as LinkModel, Prisma, link } from '@prisma/client'; import { AddROToResponseInterceptor } from './interceptors/addROToResponseInterceptor'; import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; @@ -76,7 +76,7 @@ export class AppController { const resp = await this.routerService.decodeAndRedirect(code) this.clickServiceClient .send('onClick', { - hashid: resp.hashid, + hashid: resp?.hashid, }) .subscribe(); if (resp.url !== '') { @@ -91,14 +91,16 @@ export class AppController { @ApiOperation({ summary: 'Redirect Links' }) @ApiResponse({ status: 301, description: 'will be redirected to the specified link'}) async redirect(@Param('hashid') hashid: string, @Res() res) { - const reRouteURL: string = await this.appService.redirect(hashid); - this.clickServiceClient + + const reRouteURL: string = await this.appService.resolveRedirect(hashid); + + if (reRouteURL !== '') { + console.log({reRouteURL}); + this.clickServiceClient .send('onClick', { hashid: hashid, }) .subscribe(); - if (reRouteURL !== '') { - console.log({reRouteURL}); return res.redirect(302, reRouteURL); } else { throw new NotFoundException(); @@ -110,28 +112,17 @@ export class AppController { @ApiOperation({ summary: 'Create New Links' }) @ApiBody({ type: Link }) @ApiResponse({ type: Link, status: 200}) - async register(@Body() link: Link): Promise { - return this.appService.createLink(link); + async register(@Body() link: Link): Promise { + const response:Promise = this.appService.createLinkInDB(link); + return response; } - @Patch('update/:id') @ApiOperation({ summary: 'Update Existing Links' }) @ApiBody({ type: Link }) @ApiResponse({ type: Link, status: 200}) - async update(@Param('id') id: string, @Body() link: Link ): Promise { - return this.appService.updateLink({ - where: { customHashId: id }, - data: { - userID: link.user || null, - tags: link.tags || null, - clicks: link.clicks || null, - url: link.url || null, - hashid: link.hashid || null, - project: link.project || null, - customHashId: link.customHashId || null, - }, - }); + async update(@Param('id') id: string, @Body() link: link ): Promise { + return this.appService.updateLink(id, link); } @MessagePattern('onClick') @@ -143,7 +134,9 @@ export class AppController { const channel = context.getChannelRef(); const originalMsg = context.getMessage().content.toString(); console.log(`Message: ${originalMsg}`); - await this.appService.updateClicks(JSON.parse(originalMsg).data.hashid); + // await this.appService.updateClicks(JSON.parse(originalMsg).data.hashid); + let id = JSON.parse(originalMsg).data.hashid; + await this.appService.updateClicksInPostgresDB(id).then((res) => {console.log("UPDATED IN DB SUCCESS")}).catch((err) => {console.log(err)}); } - + } diff --git a/apps/api/src/app/app.interface.ts b/apps/api/src/app/app.interface.ts index 8fd4bb13..dcfc726a 100644 --- a/apps/api/src/app/app.interface.ts +++ b/apps/api/src/app/app.interface.ts @@ -33,4 +33,8 @@ export class Link { description: 'Custom HashID of Link', }) customHashId?: string | null + @ApiProperty({ + description: 'Timestamp of Link creation', + }) + createdAt?: string | null } diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 6fb2c928..bdc24dce 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -14,6 +14,7 @@ import { TerminusModule } from '@nestjs/terminus'; import { PosthogModule } from 'nestjs-posthog'; import { ScheduleModule } from '@nestjs/schedule'; import { SchedulerService } from './scheduler/scheduler.service'; +import { RedisUtils } from './utils/redis.utils'; @Module({ imports: [ @@ -70,6 +71,6 @@ import { SchedulerService } from './scheduler/scheduler.service'; ScheduleModule.forRoot() ], controllers: [AppController], - providers: [AppService, ConfigService, RouterService, PrismaService, TelemetryService, PrismaHealthIndicator, SchedulerService], + providers: [AppService, ConfigService, RouterService, PrismaService, TelemetryService, PrismaHealthIndicator, SchedulerService,RedisUtils], }) export class AppModule {} diff --git a/apps/api/src/app/app.service.spec.ts b/apps/api/src/app/app.service.spec.ts index 9633732d..18d6f86c 100644 --- a/apps/api/src/app/app.service.spec.ts +++ b/apps/api/src/app/app.service.spec.ts @@ -11,6 +11,7 @@ import { RedisService } from 'nestjs-redis'; import { AppService } from './app.service'; import { PrismaService } from './prisma.service'; import { TelemetryService } from './telemetry/telemetry.service'; +import { RedisUtils } from './utils/redis.utils'; describe('AppService', () => { let service: AppService; @@ -83,6 +84,7 @@ describe('AppService', () => { AppService, PrismaService, TelemetryService, + RedisUtils ], }) .overrideProvider(RedisService) diff --git a/apps/api/src/app/app.service.ts b/apps/api/src/app/app.service.ts index c389dab0..9c84d2b1 100644 --- a/apps/api/src/app/app.service.ts +++ b/apps/api/src/app/app.service.ts @@ -4,6 +4,8 @@ import { PrismaService } from './prisma.service'; import { link, Prisma } from '@prisma/client'; import { ConfigService } from '@nestjs/config' import { TelemetryService } from './telemetry/telemetry.service'; +import { RedisUtils } from './utils/redis.utils'; +import { Link } from './app.interface'; @Injectable() export class AppService { @@ -12,27 +14,32 @@ export class AppService { private readonly redisService: RedisService, private prisma: PrismaService, private telemetryService: TelemetryService, + private redisUtils: RedisUtils, ) {} - async setKey(hashid: string): Promise { - const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); - client.set(hashid, 0); - } - - async updateClicks(urlId: string): Promise { - const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); - client.incr(urlId); - } - async fetchAllKeys(): Promise { - const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); - const keys: string[] = await client.keys('*'); - return keys - } + /** + * Updates the click count in the postgres db based on hashId or customhashid + * @param hashID + */ + async updateClicksInPostgresDB(id: string|number): Promise { + + // Check if given id is customhashid + Number.isNaN(parseInt(id.toString())) ? id = await this.redisUtils.fetchKey(id.toString()):0; + + const link = await this.prisma.link.findFirst({ + where: { hashid: parseInt(id.toString())}, + }) - async updateClicksInDb(): Promise { + let cnt = await this.prisma.link.update({ + where: { id: link.id }, + data: { clicks: link.clicks + 1 } + }); + } + + async updateClicksInDb(): Promise { const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); - const keys: string[] = await this.fetchAllKeys() + const keys: string[] = await this.redisUtils.fetchAllKeys() for(const key of keys) { client.get(key).then(async (value: string) => { const updateClick = await this.prisma.link.updateMany({ @@ -52,15 +59,17 @@ export class AppService { }); }); } - } + } - async link(linkWhereUniqueInput: Prisma.linkWhereUniqueInput, + // TO DO: shift to db utils + async link(linkWhereUniqueInput: Prisma.linkWhereUniqueInput, ): Promise { return this.prisma.link.findUnique({ where: linkWhereUniqueInput, }); } + // TO DO: shift to db utils async links(params: { skip?: number; take?: number; @@ -77,47 +86,180 @@ export class AppService { orderBy, }); } - - async createLink(data: Prisma.linkCreateInput): Promise { - const link = await this.prisma.link.create({ - data, - }); - this.setKey(link.hashid.toString()); - return link; + /** + * #### Persist the links in DB as well as cache to Redis after due validation. + * A valid customHashId should not be a number and shouldn't have any special characters other than hyphen and underscore. + * - Example of a valid customHashId: "my-custom-hash-id", "hasd1212" + * - Example of an invalid customHashId: "123", "my-custom-hash-id!" + * + * @param data - The link data to be persisted. + * @returns The link object that has been persisted. + */ + async createLinkInDB(data: Prisma.linkCreateInput): Promise { + const validCustomHashIdRegex = /^(?!^[0-9]+$)[a-zA-Z0-9_-]+$/; + // validate the incoming request for custom hashId + if (data.customHashId != undefined && !validCustomHashIdRegex.test(data.customHashId)) { + return Promise.reject(new Error('Invalid custom hashId. Only alphanumeric characters, hyphens and underscores are allowed.')); + } + + try { + + // create the link in DB + const link = await this.prisma.link.create({ + data:{ + ...data, + }, + }); + // set the link in redis + this.redisUtils.setKey(link); + return link; + + } catch (error) { + throw new Error('Failed to create link in the database.'); + } } - async updateLink(params: { - where: Prisma.linkWhereUniqueInput; - data: Prisma.linkUpdateInput; - }): Promise { - const { where, data } = params; - return this.prisma.link.update({ - data, - where, + /** + * updates the link in DB as well as cache to Redis after due validation. + * @param params + * @returns + */ + async updateLink( id:string|number , data:link ): Promise { + + return this.prisma.link.findFirst({ + where: { + OR: [ + { hashid: Number.isNaN(Number(id)) ? -1 : parseInt(id.toString()) }, + { customHashId: id.toString() } + ], + }, + }) + .then((link) => { + if(link == null){ + return Promise.reject(new Error('Link not found.')); + } + this.redisUtils.clearKey(link); // to clear the old key from redis + + if(!data.tags) data.tags = link.tags + if(!data.customHashId ) data.customHashId = link.customHashId + // if(!data.hashid) data.hashid = link.hashid // cannot change hashid + if(data.params){ + // update the params + const values = Object.keys(data.params); + if(link.params == null) link.params = {}; + values.forEach((value) => { + console.log(value,data.params[value]); + link.params[value] = data.params[value]; + }); + + if(link.params)data.params = link.params; + } + if(data.url != link.url) data.clicks = 0; + else data.clicks = link.clicks; + + return this.prisma.link.update({ + where : { id: link.id }, + data:data + }); + + }) + .then((link) => { + this.redisUtils.setKey(link); // to set the new key in redis + return link; }); } - + + /** + * deletes the link in DB as well as cache to Redis after due validation. + * @param where + * @returns + */ async deleteLink(where: Prisma.linkWhereUniqueInput): Promise { return this.prisma.link.delete({ where, }); } + /** + * - Wrapper around redirect + * - Resolve the hashId or customhashid + * @param Id + * @returns + */ + async resolveRedirect(Id: string): Promise { + const validHashIdRegex = /^[0-9]*$/; + if(validHashIdRegex.test(Id)){ + return this.redirect(Id); + } + else + { + const hashId = await this.redisUtils.fetchKey(Id); + if(hashId == undefined ){ + const linkData = await this.prisma.link.findFirst({ + where: { customHashId: Id}, + }); + + let response = ""; + !(linkData == null) ? response = await this.redirect(linkData.hashid.toString()):0; + return response; + } + else{ + return this.redirect(hashId); + } + } + } + + /** + * A generic function to fetch the url from redis based on hashId + * Fallback to DB if the hashId is not found in redis + * @param hashid + * @returns + */ async redirect(hashid: string): Promise { + + return this.redisUtils.fetchKey(hashid).then((value: string) => { + const link = JSON.parse(value); + + const url = link.url + const params = link.params + const ret = []; + + if(params?.["status"] == "expired"){ + return ""; + } + + if(params == null){ + return url; + }else { + Object.keys(params).forEach(function(d) { + ret.push(encodeURIComponent(d) + '=' + encodeURIComponent(params[d])); + }) + return `${url}?${ret.join('&')}` || ''; + } + }) + .catch(err => { + this.telemetryService.sendEvent(this.configService.get('POSTHOG_DISTINCT_KEY'), "Exception in fetching data from redis falling back to DB", {error: err.message}) + return this.redirectFromDB(hashid); + }); + + } + + /** + * resolves the url from DB based on hashId + * Fallback to DB if the hashId is not found in redis + * @param hashid + * @returns + */ + async redirectFromDB(hashid: string): Promise { return this.prisma.link.findMany({ where: { OR: [ { - hashid: Number.isNaN(Number(hashid))? -1:parseInt(hashid), + hashid: Number.isNaN(Number(hashid)) ? -1 : parseInt(hashid), }, { customHashId: hashid }, - ], - }, - select: { - url: true, - params: true, - hashid: true, + ] }, take: 1 }) @@ -126,7 +268,20 @@ export class AppService { const params = response[0].params const ret = []; - this.updateClicks(response[0].hashid.toString()); + const currentDate = new Date(); + var currentTime = currentDate.getTime(); + var createdAt = response[0].createdAt.getTime(); + console.log("currentTime",currentTime,"createdAt",createdAt,"expiry",params?.["expiry"]); + + if(!Number.isNaN(parseInt(params?.["expiry"])) && (currentTime > (createdAt+60*parseInt(params?.["expiry"])))){ + console.log("expired link clearing from redis"); + // delete from DB and redis !!! + // this.deleteLink({id: response[0].id}); // don't delete from DB keep it there + this.redisUtils.clearKey(response[0]); + return ""; + } + + this.redisUtils.setKey(response[0]); if(params == null){ return url; @@ -141,5 +296,5 @@ export class AppService { this.telemetryService.sendEvent(this.configService.get('POSTHOG_DISTINCT_KEY'), "Exception in getLinkFromHashIdOrCustomHashId query", {error: err.message}) return ''; }); - } + } } diff --git a/apps/api/src/app/interceptors/addROToResponseInterceptor.ts b/apps/api/src/app/interceptors/addROToResponseInterceptor.ts index e7be9514..0b31ce59 100644 --- a/apps/api/src/app/interceptors/addROToResponseInterceptor.ts +++ b/apps/api/src/app/interceptors/addROToResponseInterceptor.ts @@ -37,7 +37,7 @@ import { URLSearchParams } from 'url'; let name: string; console.log(`Execution Time: ${Date.now() - now}ms`) - const rawUrl = decodeURIComponent(req.raw.url); + const rawUrl = decodeURIComponent(req?.raw?.url); const url = rawUrl.split("?")?.[0]; const urlSearchParams = new URLSearchParams(rawUrl.split("?")?.[1]); diff --git a/apps/api/src/app/prisma/schema.prisma b/apps/api/src/app/prisma/schema.prisma index 14a5378b..07aa38f1 100644 --- a/apps/api/src/app/prisma/schema.prisma +++ b/apps/api/src/app/prisma/schema.prisma @@ -16,6 +16,7 @@ model link { hashid Int @default(autoincrement()) project String? @db.Uuid customHashId String? @unique + createdAt DateTime? @default(now()) params Json? @@unique([userID, project, url, customHashId]) diff --git a/apps/api/src/app/prisma/seed.ts b/apps/api/src/app/prisma/seed.ts index 9ad74a7f..34abf8f9 100644 --- a/apps/api/src/app/prisma/seed.ts +++ b/apps/api/src/app/prisma/seed.ts @@ -11,7 +11,8 @@ async function main() { clicks: 0, url: 'https://google.com', project: '1cd2bd98-7eba-4a4a-8cec-32eeb3648cf4', - customHashId: 'google' + customHashId: 'google', + createdAt: "2021-08-09T00:00:00.000Z", }, }) @@ -24,7 +25,8 @@ async function main() { clicks: 0, url: 'https://facebook.com', project: '4b7c207c-3c6e-440f-9e6f-29736f23a3bb', - customHashId: 'fb' + customHashId: 'fb', + createdAt: "2021-08-09T00:00:00.000Z", }, }) console.log({ alice, bob }) diff --git a/apps/api/src/app/scheduler/scheduler.service.spec.ts b/apps/api/src/app/scheduler/scheduler.service.spec.ts index f8226cb7..f88f5ea0 100644 --- a/apps/api/src/app/scheduler/scheduler.service.spec.ts +++ b/apps/api/src/app/scheduler/scheduler.service.spec.ts @@ -11,6 +11,7 @@ import { HttpModule } from "@nestjs/axios"; import { RedisHealthModule } from "@liaoliaots/nestjs-redis/health"; import { ClientsModule, Transport } from "@nestjs/microservices"; import { RedisModule } from "@liaoliaots/nestjs-redis"; +import { RedisUtils } from "../utils/redis.utils"; describe("SchedulerService", () => { let service: SchedulerService; @@ -78,7 +79,7 @@ describe("SchedulerService", () => { HttpModule, RedisHealthModule, ], - providers: [SchedulerService, AppService, PrismaService, TelemetryService, ], + providers: [SchedulerService, AppService, PrismaService, TelemetryService,RedisUtils ], }) .overrideProvider(RedisService) .useValue(mockRedisService) diff --git a/apps/api/src/app/scheduler/scheduler.service.ts b/apps/api/src/app/scheduler/scheduler.service.ts index f7c503b6..52a2fa21 100644 --- a/apps/api/src/app/scheduler/scheduler.service.ts +++ b/apps/api/src/app/scheduler/scheduler.service.ts @@ -10,24 +10,6 @@ export class SchedulerService { constructor( private configService: ConfigService, private readonly telemetryService: TelemetryService, - protected readonly appService: AppService){} - -// TODO: add dynamic configuration - @Cron(process.env.CLICK_BACKUP_CRON) - async handleCron() { - const cronId = uuidv4(); - try { - this.telemetryService.sendEvent(this.configService.get('POSTHOG_DISTINCT_KEY'), "updateClicksInDb cron started", {cronId: cronId, ts: Date.now()}) - this.appService.updateClicksInDb(); - } - catch (err) { - this.telemetryService.sendEvent(this.configService.get('POSTHOG_DISTINCT_KEY'), "Exception in updateClicksInDb cron", {error: err.message}) - return ''; - } - finally { - this.telemetryService.sendEvent(this.configService.get('POSTHOG_DISTINCT_KEY'), "updateClicksInDb cron completed", {cronId: cronId, ts: Date.now()}) - return ''; - } - } + protected readonly appService: AppService){} } diff --git a/apps/api/src/app/utils/redis.utils.ts b/apps/api/src/app/utils/redis.utils.ts new file mode 100644 index 00000000..1684fc4c --- /dev/null +++ b/apps/api/src/app/utils/redis.utils.ts @@ -0,0 +1,104 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { RedisService } from '@liaoliaots/nestjs-redis'; +import { ConfigService } from '@nestjs/config' +import { link } from '@prisma/client'; + +@Injectable() +export class RedisUtils { + constructor( + private configService: ConfigService, + private readonly redisService: RedisService, +) {} + + /** + * A generic function to set key value in redis + * @param key + * @param value + */ + async setKeyValueInRedis(key: string, value: string): Promise { + const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); + client.set(key, value); + } + + /** + * A generic function to set expiry for a key + * @param key + * @param ttl + */ + async setKeyWithExpiry(key:string,ttl:number): Promise { + const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); + client.expire(key, ttl); + } + + /** + * set the link data in redis with expiry + * @param Data + */ + async setKey(Data:link): Promise { + // expiration in seconds + let ttl = parseInt(Data?.params?.["expiry"]) ?? NaN; + this.setKeyValueInRedis(Data.hashid.toString(), JSON.stringify(Data)); + !Number.isNaN(ttl) ? this.setKeyWithExpiry(Data.hashid.toString(), ttl) : 0; + + if(!Number.isNaN(Data.customHashId) && Data.customHashId !== null) { + // console.log("custom hash id found"+Data.customHashId); + this.setKeyValueInRedis(Data.customHashId, Data.hashid.toString()); + !Number.isNaN(ttl) ? this.setKeyWithExpiry(Data.customHashId, ttl) : 0; + } + + } + + /** + * clear the link data in redis + * @param Data + */ + async clearKey(Data:link): Promise { + const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); + // Test this + client.del(Data.hashid.toString()); + client.del(Data.customHashId); + } + + /** + * fetch a link data from redis + * @param key + * @returns + */ + async fetchKey(key: string): Promise { + const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); + const value: string = (await client.get(key))?.toString(); + return value + } + + /** + * fetch all keys from redis + * @returns + */ + async fetchAllKeys(): Promise { + const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); + const keys: string[] = await client.keys('*'); + return keys + } + + /** + * update clicks in redis + * @Deprecated + * @param urlId + */ + async updateClicks(urlId: string): Promise { + const client = this.redisService.getClient(this.configService.get('REDIS_NAME')); + // client.get(urlId).then(async (value: string) => {}); + client.incr(urlId); + } + + /** + * create a link in redis + * @deprecated + * @param data + * @returns + */ + async createLinkInCache(data: link): Promise { + this.setKey(data); + return data; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index dbfe4f7a..b28fa32c 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -22,17 +22,17 @@ async function bootstrap() { ); app.enableCors() - // // MS for managing side-effects - // app.connectMicroservice({ - // transport: Transport.RMQ, - // options: { - // urls: ['amqp://username:password@localhost:5672'], - // queue: 'clicks', - // queueOptions: { - // durable: false, - // }, - // }, - // }); + // MS for managing side-effects + app.connectMicroservice({ + transport: Transport.RMQ, + options: { + urls: ['amqp://username:password@localhost:5672'], + queue: 'clicks', + queueOptions: { + durable: true, + }, + }, + }); const globalPrefix = process.env.APP_GLOBAL_PREFIX || ''; app.setGlobalPrefix(globalPrefix);