From 40dd8e65de3d171cb03ea6d95c0bd0faf267498e Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 19 Jul 2023 22:53:15 +0530 Subject: [PATCH 1/2] Feat (link-expiry) : Modified prisma schema --- apps/api/src/app/prisma/schema.prisma | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/prisma/schema.prisma b/apps/api/src/app/prisma/schema.prisma index 14a5378b..ed64c431 100644 --- a/apps/api/src/app/prisma/schema.prisma +++ b/apps/api/src/app/prisma/schema.prisma @@ -17,7 +17,8 @@ model link { project String? @db.Uuid customHashId String? @unique params Json? - + state State @default(Draft) + expiry BigInt? @@unique([userID, project, url, customHashId]) } @@ -26,3 +27,9 @@ model template { text String? name Unsupported("name") } + +enum State { + Draft + Live + Expire +} \ No newline at end of file From de2117771a357407bf2104c85ea6372cad02c330 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Sun, 23 Jul 2023 16:05:30 +0530 Subject: [PATCH 2/2] Feat(link-expiry) : Support for link expiry --- apps/api/src/app/app.controller.ts | 133 +++++++++------ apps/api/src/app/app.service.ts | 234 +++++++++++++++----------- apps/api/src/app/prisma/schema.prisma | 8 - 3 files changed, 211 insertions(+), 164 deletions(-) diff --git a/apps/api/src/app/app.controller.ts b/apps/api/src/app/app.controller.ts index 2167d75b..c0c65f04 100644 --- a/apps/api/src/app/app.controller.ts +++ b/apps/api/src/app/app.controller.ts @@ -10,33 +10,37 @@ import { Put, Res, UseInterceptors, -} from '@nestjs/common'; +} from "@nestjs/common"; import { ClientProxy, Ctx, MessagePattern, Payload, RmqContext, -} from '@nestjs/microservices'; +} from "@nestjs/microservices"; -import { HealthCheckService, HttpHealthIndicator, HealthCheck } from '@nestjs/terminus'; -import { PrismaHealthIndicator } from './prisma/prisma.health'; -import { RedisService } from '@liaoliaots/nestjs-redis'; -import { RedisHealthIndicator } from '@liaoliaots/nestjs-redis/health'; -import Redis from 'ioredis'; -import { Link } from './app.interface'; +import { + HealthCheckService, + HttpHealthIndicator, + HealthCheck, +} from "@nestjs/terminus"; +import { PrismaHealthIndicator } from "./prisma/prisma.health"; +import { RedisService } from "@liaoliaots/nestjs-redis"; +import { RedisHealthIndicator } from "@liaoliaots/nestjs-redis/health"; +import Redis from "ioredis"; +import { Link } from "./app.interface"; -import { AppService } from './app.service'; -import { RouterService } from './router/router.service'; -import { link as LinkModel } from '@prisma/client'; -import { AddROToResponseInterceptor } from './interceptors/addROToResponseInterceptor'; -import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { ConfigService } from '@nestjs/config'; +import { AppService } from "./app.service"; +import { RouterService } from "./router/router.service"; +import { link as LinkModel } from "@prisma/client"; +import { AddROToResponseInterceptor } from "./interceptors/addROToResponseInterceptor"; +import { ApiBody, ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { ConfigService } from "@nestjs/config"; @Controller() @UseInterceptors(AddROToResponseInterceptor) export class AppController { - private readonly redis: Redis + private readonly redis: Redis; constructor( private readonly appService: AppService, @@ -47,39 +51,54 @@ export class AppController { private prismaIndicator: PrismaHealthIndicator, private readonly configService: ConfigService, private readonly redisService: RedisService, - @Inject('CLICK_SERVICE') private clickServiceClient: ClientProxy + @Inject("CLICK_SERVICE") private clickServiceClient: ClientProxy ) { - this.redis = redisService.getClient(configService.get('REDIS_NAME')); + this.redis = redisService.getClient(configService.get("REDIS_NAME")); } - - @Get('/health') + @Get("/health") @HealthCheck() - @ApiOperation({ summary: 'Get Health Check Status' }) - @ApiResponse({ status: 200, description: 'Result Report for All the Health Check Services' }) + @ApiOperation({ summary: "Get Health Check Status" }) + @ApiResponse({ + status: 200, + description: "Result Report for All the Health Check Services", + }) async checkHealth() { return this.healthCheckService.check([ - async () => this.http.pingCheck('RabbitMQ', this.configService.get('RABBITMQ_HEALTH_URL')), - async () => this.http.pingCheck('Basic Check', this.configService.get('BASE_URL')), - async () => this.redisIndicator.checkHealth('Redis', { type: 'redis', client: this.redis, timeout: 500 }), - async () => this.prismaIndicator.isHealthy('Db'), - ]) + async () => + this.http.pingCheck( + "RabbitMQ", + this.configService.get("RABBITMQ_HEALTH_URL") + ), + async () => + this.http.pingCheck("Basic Check", this.configService.get("BASE_URL")), + async () => + this.redisIndicator.checkHealth("Redis", { + type: "redis", + client: this.redis, + timeout: 500, + }), + async () => this.prismaIndicator.isHealthy("Db"), + ]); } - -/* + + /* @Deprecated */ -@Get('/sr/:code') -@ApiOperation({ summary: 'Redirect with encoded parameters' }) -@ApiResponse({ status: 301, description: 'will be redirected to the specified encoded link'}) - async handler(@Param('code') code: string, @Res() res) { - const resp = await this.routerService.decodeAndRedirect(code) + @Get("/sr/:code") + @ApiOperation({ summary: "Redirect with encoded parameters" }) + @ApiResponse({ + status: 301, + description: "will be redirected to the specified encoded link", + }) + async handler(@Param("code") code: string, @Res() res) { + const resp = await this.routerService.decodeAndRedirect(code); this.clickServiceClient - .send('onClick', { + .send("onClick", { hashid: resp.hashid, }) .subscribe(); - if (resp.url !== '') { + if (resp.url !== "") { return res.redirect(resp.url); } else { throw new NotFoundException(); @@ -87,42 +106,47 @@ export class AppController { } //http://localhost:3333/api/redirect/208 - @Get('/:hashid') - @ApiOperation({ summary: 'Redirect Links' }) - @ApiResponse({ status: 301, description: 'will be redirected to the specified link'}) - async redirect(@Param('hashid') hashid: string, @Res() res) { + @Get("/:hashid") + @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 - .send('onClick', { + .send("onClick", { hashid: hashid, }) .subscribe(); - if (reRouteURL !== '') { - console.log({reRouteURL}); + console.log({ reRouteURL }); + if (reRouteURL !== "") { + console.log({ reRouteURL }); return res.redirect(302, reRouteURL); } else { throw new NotFoundException(); } } - - @Post('/register') - @ApiOperation({ summary: 'Create New Links' }) + @Post("/register") + @ApiOperation({ summary: "Create New Links" }) @ApiBody({ type: Link }) - @ApiResponse({ type: Link, status: 200}) + @ApiResponse({ type: Link, status: 200 }) async register(@Body() link: Link): Promise { return this.appService.createLink(link); } - - @Patch('update/:id') - @ApiOperation({ summary: 'Update Existing Links' }) + @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 { + @ApiResponse({ type: Link, status: 200 }) + async update( + @Param("id") id: string, + @Body() link: Link + ): Promise { return this.appService.updateLink({ where: { customHashId: id }, - data: { + data: { userID: link.user || null, tags: link.tags || null, clicks: link.clicks || null, @@ -130,11 +154,11 @@ export class AppController { hashid: link.hashid || null, project: link.project || null, customHashId: link.customHashId || null, - }, + }, }); } - @MessagePattern('onClick') + @MessagePattern("onClick") async getNotifications( @Payload() data: number[], @Ctx() context: RmqContext @@ -145,5 +169,4 @@ export class AppController { console.log(`Message: ${originalMsg}`); await this.appService.updateClicks(JSON.parse(originalMsg).data.hashid); } - } diff --git a/apps/api/src/app/app.service.ts b/apps/api/src/app/app.service.ts index c389dab0..1aa19dab 100644 --- a/apps/api/src/app/app.service.ts +++ b/apps/api/src/app/app.service.ts @@ -1,9 +1,9 @@ -import { Injectable } from '@nestjs/common'; -import { RedisService } from '@liaoliaots/nestjs-redis'; -import { PrismaService } from './prisma.service'; -import { link, Prisma } from '@prisma/client'; -import { ConfigService } from '@nestjs/config' -import { TelemetryService } from './telemetry/telemetry.service'; +import { Injectable } from "@nestjs/common"; +import { RedisService } from "@liaoliaots/nestjs-redis"; +import { PrismaService } from "./prisma.service"; +import { link, Prisma } from "@prisma/client"; +import { ConfigService } from "@nestjs/config"; +import { TelemetryService } from "./telemetry/telemetry.service"; @Injectable() export class AppService { @@ -11,29 +11,37 @@ export class AppService { private configService: ConfigService, private readonly redisService: RedisService, private prisma: PrismaService, - private telemetryService: TelemetryService, - ) {} + private telemetryService: TelemetryService + ) {} async setKey(hashid: string): Promise { - const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); + 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')); + 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 + const client = await this.redisService.getClient( + this.configService.get("REDIS_NAME") + ); + const keys: string[] = await client.keys("*"); + return keys; } async updateClicksInDb(): Promise { - const client = await this.redisService.getClient(this.configService.get('REDIS_NAME')); - const keys: string[] = await this.fetchAllKeys() - for(const key of keys) { + const client = await this.redisService.getClient( + this.configService.get("REDIS_NAME") + ); + const keys: string[] = await this.fetchAllKeys(); + for (const key of keys) { client.get(key).then(async (value: string) => { const updateClick = await this.prisma.link.updateMany({ where: { @@ -54,92 +62,116 @@ export class AppService { } } - async link(linkWhereUniqueInput: Prisma.linkWhereUniqueInput, - ): Promise { - return this.prisma.link.findUnique({ - where: linkWhereUniqueInput, - }); - } + async link( + linkWhereUniqueInput: Prisma.linkWhereUniqueInput + ): Promise { + return this.prisma.link.findUnique({ + where: linkWhereUniqueInput, + }); + } - async links(params: { - skip?: number; - take?: number; - cursor?: Prisma.linkWhereUniqueInput; - where?: Prisma.linkWhereInput; - orderBy?: Prisma.linkOrderByWithRelationInput; - }): Promise { - const { skip, take, cursor, where, orderBy } = params; - return this.prisma.link.findMany({ - skip, - take, - cursor, - where, - orderBy, - }); - } - - async createLink(data: Prisma.linkCreateInput): Promise { - const link = await this.prisma.link.create({ - data, - }); + async links(params: { + skip?: number; + take?: number; + cursor?: Prisma.linkWhereUniqueInput; + where?: Prisma.linkWhereInput; + orderBy?: Prisma.linkOrderByWithRelationInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prisma.link.findMany({ + skip, + take, + cursor, + where, + orderBy, + }); + } - this.setKey(link.hashid.toString()); - return link; - } + async createLink(data: Prisma.linkCreateInput): Promise { + const link = await this.prisma.link.create({ + data, + }); - async updateLink(params: { - where: Prisma.linkWhereUniqueInput; - data: Prisma.linkUpdateInput; - }): Promise { - const { where, data } = params; - return this.prisma.link.update({ - data, - where, - }); - } - - async deleteLink(where: Prisma.linkWhereUniqueInput): Promise { - return this.prisma.link.delete({ - where, - }); - } + this.setKey(link.hashid.toString()); + return link; + } - async redirect(hashid: string): Promise { - return this.prisma.link.findMany({ - where: { - OR: [ - { - hashid: Number.isNaN(Number(hashid))? -1:parseInt(hashid), - }, - { customHashId: hashid }, - ], - }, - select: { - url: true, - params: true, - hashid: true, - }, - take: 1 - }) - .then(response => { - const url = response[0].url - const params = response[0].params - const ret = []; - - this.updateClicks(response[0].hashid.toString()); - - 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 getLinkFromHashIdOrCustomHashId query", {error: err.message}) - return ''; - }); - } + async updateLink(params: { + where: Prisma.linkWhereUniqueInput; + data: Prisma.linkUpdateInput; + }): Promise { + const { where, data } = params; + return this.prisma.link.update({ + data, + where, + }); + } + + async deleteLink(where: Prisma.linkWhereUniqueInput): Promise { + return this.prisma.link.delete({ + where, + }); + } + + async redirect(hashid: string): Promise { + return this.prisma.link + .findMany({ + where: { + OR: [ + { + hashid: Number.isNaN(Number(hashid)) ? -1 : parseInt(hashid), + }, + { customHashId: hashid }, + ], + }, + select: { + url: true, + params: true, + hashid: true, + }, + take: 1, + }) + .then((response) => { + const url = response[0].url; + const params = response[0].params; + const ret = []; + + const now = new Date(); // get the current date and time + const timestamp = now.getTime(); // get the timestamp + + console.log(now, timestamp, parseInt(params["expiry"])); + + // check if the link has expired + // assumption : expired link === no link found + + if ( + !Number.isNaN(Number(params["expiry"])) && + parseInt(params["expiry"]) < timestamp + ) { + console.log("Link has expired"); + return ""; + } + + this.updateClicks(response[0].hashid.toString()); + + 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 getLinkFromHashIdOrCustomHashId query", + { error: err.message } + ); + return ""; + }); + } } diff --git a/apps/api/src/app/prisma/schema.prisma b/apps/api/src/app/prisma/schema.prisma index ed64c431..97f4295f 100644 --- a/apps/api/src/app/prisma/schema.prisma +++ b/apps/api/src/app/prisma/schema.prisma @@ -17,8 +17,6 @@ model link { project String? @db.Uuid customHashId String? @unique params Json? - state State @default(Draft) - expiry BigInt? @@unique([userID, project, url, customHashId]) } @@ -27,9 +25,3 @@ model template { text String? name Unsupported("name") } - -enum State { - Draft - Live - Expire -} \ No newline at end of file