From 59ba268d3b3810a7a24ec3969ca9a8037ea461ef Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Thu, 30 Oct 2025 09:36:50 +0545 Subject: [PATCH 01/13] feat(analytics): add os column --- src/analytics/analytics.entity.ts | 3 ++ src/analytics/analytics.service.ts | 48 +++++++++++++++++-- .../dto/filter-analytics-request-data.ts | 12 +++++ src/auth/auth.guard.ts | 2 + src/migrations/1760416146118-createTable.ts | 22 --------- .../1761545874535-createAnalyticsTable.ts | 13 ++--- src/url/url.controller.ts | 3 +- src/url/url.entity.ts | 3 +- src/url/url.service.ts | 5 +- 9 files changed, 72 insertions(+), 39 deletions(-) create mode 100644 src/analytics/dto/filter-analytics-request-data.ts delete mode 100644 src/migrations/1760416146118-createTable.ts diff --git a/src/analytics/analytics.entity.ts b/src/analytics/analytics.entity.ts index 5091b69..09204a3 100644 --- a/src/analytics/analytics.entity.ts +++ b/src/analytics/analytics.entity.ts @@ -22,6 +22,9 @@ export class UrlAnalytics { @Column({ type: 'varchar', length: 50, nullable: true, name: 'device' }) readonly device: string; + @Column({ type: 'varchar', length: 40, nullable: true, name: 'os' }) + readonly os: string; + @Column({ type: 'varchar', length: 40, nullable: true, name: 'browser' }) readonly browser: string; diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index dfdd94a..9182f6a 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -19,10 +19,9 @@ export class AnalyticsService { async recordClick(event: UrlRedirectedEvent): Promise { const urlId = event.urlId; const req = event.req; - const ip = - (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || - req.socket?.remoteAddress?.replace('::ffff:', '') || - '0.0.0.0'; + const ip = (req.headers['x-forwarded-for'] as string) + ?.split(',')[0] + ?.trim(); const userAgent = req.headers['user-agent'] || ''; const parsed = ( @@ -32,6 +31,7 @@ export class AnalyticsService { ).parse(userAgent); const deviceMatch = parsed.source.match(/\(([^;]+);/); + console.log('/////////////////////////', deviceMatch); const device = deviceMatch ? deviceMatch[1] : 'Unknown Device'; const geo = geoip.lookup(ip); const country = geo?.country || 'Unknown'; @@ -47,4 +47,44 @@ export class AnalyticsService { await this.analyticsRepo.save(analytics); } + + async getAnalytics(filters: { + startDate?: string; + endDate?: string; + browser?: string; + device?: string; + os?: string; + groupByUrl?: boolean; + }) { + const qb = this.analyticsRepo.createQueryBuilder('a'); + + if (filters.startDate && filters.endDate) { + qb.andWhere('a.clickedAt BETWEEN :start AND :end', { + start: filters.startDate, + end: filters.endDate, + }); + } + + if (filters.browser) { + qb.andWhere('a.browser = :browser', { browser: filters.browser }); + } + + if (filters.device) { + qb.andWhere('a.device = :device', { device: filters.device }); + } + + if (filters.os) { + qb.andWhere('a.os = :os', { os: filters.os }); + } + + if (filters.groupByUrl) { + qb.select('a.url', 'url') + .addSelect('COUNT(*)', 'hits') + .groupBy('a.url') + .orderBy('hits', 'DESC'); + return qb.getRawMany(); + } + + return qb.getMany(); + } } diff --git a/src/analytics/dto/filter-analytics-request-data.ts b/src/analytics/dto/filter-analytics-request-data.ts new file mode 100644 index 0000000..be48026 --- /dev/null +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -0,0 +1,12 @@ +import { IsDate } from 'class-validator'; + +export class FilterAnalyticsRequestData { + browser?: string; + device?: string; + groupByUrl?: boolean; + urlId: string; + country: string; + ip: string; + @IsDate({ message: 'redirected date must be a valid date' }) + redirectedAt: Date; +} diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts index 53a3193..f0bb2e7 100644 --- a/src/auth/auth.guard.ts +++ b/src/auth/auth.guard.ts @@ -25,9 +25,11 @@ export class AuthGuard implements CanActivate { const decoded = await this.authService.validateToken(token); request.decodedData = decoded; const userData = request.decodedData; + if (!userData) { throw new UnauthorizedException('Invalid or missing token'); } + return true; } } diff --git a/src/migrations/1760416146118-createTable.ts b/src/migrations/1760416146118-createTable.ts deleted file mode 100644 index bb0b50f..0000000 --- a/src/migrations/1760416146118-createTable.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateUsersTable1760500000000 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TABLE "users" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "username" varchar(255) NOT NULL UNIQUE, - "full_name" varchar(255) NOT NULL, - "email" varchar(255) NOT NULL UNIQUE, - "password" varchar NOT NULL, - "verified_at" timestamp with time zone DEFAULT NULL, - "created_at" timestamp with time zone DEFAULT now(), - "last_login_at" timestamp with time zone DEFAULT NULL - ); - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "users";`); - } -} diff --git a/src/migrations/1761545874535-createAnalyticsTable.ts b/src/migrations/1761545874535-createAnalyticsTable.ts index bdeebc2..db283f8 100644 --- a/src/migrations/1761545874535-createAnalyticsTable.ts +++ b/src/migrations/1761545874535-createAnalyticsTable.ts @@ -5,13 +5,14 @@ export class CreateAnalyticsTable1761545874535 implements MigrationInterface { await queryRunner.query(` CREATE TABLE "url_analytics" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "url_id" uuid NOT NULL, + "url_id" uuid , "redirected_at" timestamp with time zone DEFAULT NOW(), - "ip" VARCHAR(100) NOT NULL, - "country" VARCHAR(40) NOT NULL, - "device" VARCHAR(50) NOT NULL, - "browser" VARCHAR(40) NOT NULL, - "user_agent" VARCHAR(200) NOT NULL, + "ip" VARCHAR(100) , + "country" VARCHAR(40) , + "os" VARCHAR(40) , + "device" VARCHAR(50), + "browser" VARCHAR(40) , + "user_agent" VARCHAR(200), CONSTRAINT "fk_url_analytics" FOREIGN KEY ("url_id") REFERENCES "urls" ("id") ON DELETE CASCADE ); diff --git a/src/url/url.controller.ts b/src/url/url.controller.ts index e34dc2c..e024e74 100644 --- a/src/url/url.controller.ts +++ b/src/url/url.controller.ts @@ -13,14 +13,13 @@ import { Req, UseGuards, } from '@nestjs/common'; -import type Request from 'express'; import { UrlService } from './url.service'; import { CreateUrlRequestData } from './dto/create-url-request-data'; import { AuthGuard } from 'src/auth/auth.guard'; import type { RequestWithUser } from 'src/types/RequestWithUser'; import { UpdateUrlRequestData } from './dto/update-url-request-data'; -@Controller('url') +@Controller('urls') export class UrlController { constructor(private readonly urlService: UrlService) {} diff --git a/src/url/url.entity.ts b/src/url/url.entity.ts index 9c10195..3b8241e 100644 --- a/src/url/url.entity.ts +++ b/src/url/url.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, + DeleteDateColumn, } from 'typeorm'; import { User } from 'src/user/user.entity'; @@ -40,7 +41,7 @@ export class Url { }) readonly expiryAlertedAt: Date | null; - @Column({ + @DeleteDateColumn({ type: 'timestamp with time zone', nullable: true, name: 'deleted_at', diff --git a/src/url/url.service.ts b/src/url/url.service.ts index e4aef5d..b03efc6 100644 --- a/src/url/url.service.ts +++ b/src/url/url.service.ts @@ -113,9 +113,6 @@ export class UrlService { } async delete(userId: string, urlId: string): Promise { - if (!urlId) { - throw new BadRequestException('URL id is required'); - } const existingUrl = await this.urlRepository.findOneBy({ id: urlId, userId: userId, @@ -125,7 +122,7 @@ export class UrlService { throw new NotFoundException(`Url with ID ${urlId} not found`); } - const deletedUrl = await this.urlRepository.delete({ id: urlId }); + const deletedUrl = await this.urlRepository.softDelete({ id: urlId }); if (deletedUrl.affected === 0) { throw new NotFoundException('URL not found'); } From 41c0dd05ebb457ddab0f82dd5fe18ac77557dc2a Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Thu, 30 Oct 2025 10:27:28 +0545 Subject: [PATCH 02/13] removed an unnecessary migration file --- src/migrations/1760416146118-createTable.ts | 22 --------------------- 1 file changed, 22 deletions(-) delete mode 100644 src/migrations/1760416146118-createTable.ts diff --git a/src/migrations/1760416146118-createTable.ts b/src/migrations/1760416146118-createTable.ts deleted file mode 100644 index bb0b50f..0000000 --- a/src/migrations/1760416146118-createTable.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateUsersTable1760500000000 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TABLE "users" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "username" varchar(255) NOT NULL UNIQUE, - "full_name" varchar(255) NOT NULL, - "email" varchar(255) NOT NULL UNIQUE, - "password" varchar NOT NULL, - "verified_at" timestamp with time zone DEFAULT NULL, - "created_at" timestamp with time zone DEFAULT now(), - "last_login_at" timestamp with time zone DEFAULT NULL - ); - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "users";`); - } -} From 4d984729f58aad9e203b36f37906cc081a1047a7 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Thu, 30 Oct 2025 13:28:47 +0545 Subject: [PATCH 03/13] feat(analytics):add url filter endpoints and service --- src/analytics/analytics.controller.ts | 14 +++++++ src/analytics/analytics.module.ts | 3 +- src/analytics/analytics.service.ts | 36 ++++++++---------- .../dto/filter-analytics-request-data.ts | 37 ++++++++++++++++--- src/analytics/types.ts | 6 +++ 5 files changed, 68 insertions(+), 28 deletions(-) create mode 100644 src/analytics/analytics.controller.ts diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..5d55485 --- /dev/null +++ b/src/analytics/analytics.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data'; +import { AnalyticsService } from './analytics.service'; + +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @HttpCode(HttpStatus.CREATED) + @Get() + async filterUrlAnalytics(@Body() body: FilterAnalyticsRequestData) { + return this.analyticsService.getAnalytics(body); + } +} diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts index 11fba08..3a1958b 100644 --- a/src/analytics/analytics.module.ts +++ b/src/analytics/analytics.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AnalyticsService } from './analytics.service'; import { UrlAnalytics } from './analytics.entity'; +import { AnalyticsController } from './analytics.controller'; @Module({ imports: [TypeOrmModule.forFeature([UrlAnalytics])], providers: [AnalyticsService], exports: [], - controllers: [], + controllers: [AnalyticsController], }) export class AnalyticsModule {} diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index 9182f6a..b79f4c7 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { Request } from 'express'; import geoip from 'geoip-lite'; import { UrlAnalytics } from './analytics.entity'; import { InjectRepository } from '@nestjs/typeorm'; @@ -8,6 +7,7 @@ import useragent from 'useragent'; import { ParsedUserAgent } from './types'; import { OnEvent } from '@nestjs/event-emitter'; import { UrlRedirectedEvent } from 'src/event/Url-redirected.events'; +import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data'; @Injectable() export class AnalyticsService { constructor( @@ -31,13 +31,14 @@ export class AnalyticsService { ).parse(userAgent); const deviceMatch = parsed.source.match(/\(([^;]+);/); - console.log('/////////////////////////', deviceMatch); const device = deviceMatch ? deviceMatch[1] : 'Unknown Device'; const geo = geoip.lookup(ip); const country = geo?.country || 'Unknown'; + const os = parsed.os.family; const analytics = this.analyticsRepo.create({ urlId, + os, ip, browser: `${parsed.family} ${parsed.major}.${parsed.minor}.${parsed.patch} `, userAgent, @@ -48,36 +49,29 @@ export class AnalyticsService { await this.analyticsRepo.save(analytics); } - async getAnalytics(filters: { - startDate?: string; - endDate?: string; - browser?: string; - device?: string; - os?: string; - groupByUrl?: boolean; - }) { + async getAnalytics(requestData: FilterAnalyticsRequestData) { const qb = this.analyticsRepo.createQueryBuilder('a'); - if (filters.startDate && filters.endDate) { - qb.andWhere('a.clickedAt BETWEEN :start AND :end', { - start: filters.startDate, - end: filters.endDate, + if (requestData.startDate && requestData.endDate) { + qb.andWhere('a.redirectedAt BETWEEN :start AND :end', { + start: requestData.startDate, + end: requestData.endDate, }); } - if (filters.browser) { - qb.andWhere('a.browser = :browser', { browser: filters.browser }); + if (requestData.browser) { + qb.andWhere('a.browser = :browser', { browser: requestData.browser }); } - if (filters.device) { - qb.andWhere('a.device = :device', { device: filters.device }); + if (requestData.device) { + qb.andWhere('a.device = :device', { device: requestData.device }); } - if (filters.os) { - qb.andWhere('a.os = :os', { os: filters.os }); + if (requestData.os) { + qb.andWhere('a.os = :os', { os: requestData.os }); } - if (filters.groupByUrl) { + if (requestData.groupByUrl) { qb.select('a.url', 'url') .addSelect('COUNT(*)', 'hits') .groupBy('a.url') diff --git a/src/analytics/dto/filter-analytics-request-data.ts b/src/analytics/dto/filter-analytics-request-data.ts index be48026..f85adc1 100644 --- a/src/analytics/dto/filter-analytics-request-data.ts +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -1,12 +1,37 @@ -import { IsDate } from 'class-validator'; +import { IsDate, IsOptional } from 'class-validator'; export class FilterAnalyticsRequestData { + @IsOptional() browser?: string; + + @IsOptional() device?: string; + + @IsOptional() groupByUrl?: boolean; - urlId: string; - country: string; - ip: string; - @IsDate({ message: 'redirected date must be a valid date' }) - redirectedAt: Date; + + @IsOptional() + urlId?: string; + + @IsOptional() + os?: string; + + @IsOptional() + country?: string; + + @IsOptional() + ip?: string; + + @IsOptional() + @IsDate({ message: 'Redirected date must be a valid date' }) + redirectedAt?: Date; + + @IsOptional() + @IsDate({ message: 'Starting date must be a valid date' }) + @IsOptional() + startDate?: Date; + + @IsDate({ message: 'End date must be a valid date' }) + @IsOptional() + endDate?: Date; } diff --git a/src/analytics/types.ts b/src/analytics/types.ts index 3205200..756e712 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -10,4 +10,10 @@ export type ParsedUserAgent = { minor: string; patch: string; }; + os: { + family: string; + major: string; + minor: string; + patch: string; + }; }; From 72fb87acaf60dee74b0748d61791b4289920be70 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Thu, 30 Oct 2025 14:52:52 +0545 Subject: [PATCH 04/13] fix: addressed pr review changes --- src/analytics/analytics.service.ts | 3 +-- src/analytics/types.ts | 15 +++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index d355d27..7424b6f 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -30,11 +30,10 @@ export class AnalyticsService { const device = deviceMatch ? deviceMatch[1] : 'Unknown Device'; const geo = geoip.lookup(ip); const country = geo?.country || 'Unknown'; - const analytics = this.analyticsRepo.create({ urlId, ip, - browser: `${parsed.family} ${parsed.major}.${parsed.minor}.${parsed.patch} `, + browser: `${parsed.browser.family} ${parsed.browser.major}.${parsed.browser.minor}.${parsed.browser.patch} `, userAgent, device: device, country, diff --git a/src/analytics/types.ts b/src/analytics/types.ts index 3205200..60af17f 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -1,13 +1,12 @@ export type ParsedUserAgent = { + browser: SubString; + source: string; + device: SubString; +}; + +interface SubString { family: string; major: string; minor: string; patch: string; - source: string; - device: { - family: string; - major: string; - minor: string; - patch: string; - }; -}; +} From bff9f3f6ebc4fb4e7835c71204ad65f015b851e7 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Thu, 30 Oct 2025 15:47:11 +0545 Subject: [PATCH 05/13] feat(analytics): updated types and analytic service --- src/analytics/analytics.service.ts | 8 +++++++- src/analytics/types.ts | 9 ++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index 7424b6f..38e889a 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -28,12 +28,18 @@ export class AnalyticsService { const deviceMatch = parsed.source.match(/\(([^;]+);/); const device = deviceMatch ? deviceMatch[1] : 'Unknown Device'; + + const browserMatch = parsed.source.match( + /(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/, + ); + const browser = browserMatch ? browserMatch[0] : 'Unknown Browser'; + const geo = geoip.lookup(ip); const country = geo?.country || 'Unknown'; const analytics = this.analyticsRepo.create({ urlId, ip, - browser: `${parsed.browser.family} ${parsed.browser.major}.${parsed.browser.minor}.${parsed.browser.patch} `, + browser: browser, userAgent, device: device, country, diff --git a/src/analytics/types.ts b/src/analytics/types.ts index 60af17f..abf428c 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -1,12 +1,7 @@ export type ParsedUserAgent = { - browser: SubString; - source: string; - device: SubString; -}; - -interface SubString { family: string; major: string; minor: string; patch: string; -} + source: string; +}; From d4d33325212a07e759fa1a7c34e63bb8081ae075 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Fri, 31 Oct 2025 07:09:00 +0545 Subject: [PATCH 06/13] feat(analytics): fixed getAnalytics service and controller --- src/analytics/analytics.controller.ts | 24 +++++++++++++++---- src/analytics/analytics.entity.ts | 2 +- src/analytics/analytics.module.ts | 3 ++- src/analytics/analytics.service.ts | 15 ++++++++---- .../dto/filter-analytics-request-data.ts | 12 ++++------ 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts index 5d55485..9a301c4 100644 --- a/src/analytics/analytics.controller.ts +++ b/src/analytics/analytics.controller.ts @@ -1,14 +1,30 @@ -import { Body, Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data'; import { AnalyticsService } from './analytics.service'; +import { AuthGuard } from 'src/auth/auth.guard'; +import type { RequestWithUser } from 'src/types/RequestWithUser'; @Controller('analytics') export class AnalyticsController { constructor(private readonly analyticsService: AnalyticsService) {} - @HttpCode(HttpStatus.CREATED) + @UseGuards(AuthGuard) + @HttpCode(HttpStatus.OK) @Get() - async filterUrlAnalytics(@Body() body: FilterAnalyticsRequestData) { - return this.analyticsService.getAnalytics(body); + async filterUrlAnalytics( + @Query() query: FilterAnalyticsRequestData, + @Req() request: RequestWithUser, + ) { + const userId = request.decodedData.sub; + return this.analyticsService.getAnalytics(query, userId); } } diff --git a/src/analytics/analytics.entity.ts b/src/analytics/analytics.entity.ts index 09204a3..6d51c64 100644 --- a/src/analytics/analytics.entity.ts +++ b/src/analytics/analytics.entity.ts @@ -34,7 +34,7 @@ export class UrlAnalytics { @Column({ type: 'varchar', length: 255, nullable: true, name: 'user_agent' }) readonly userAgent: string; - @CreateDateColumn({ type: 'timestamp with time zone', name: 'redirected_at' }) + @CreateDateColumn({ type: 'timestamptz', name: 'redirected_at' }) readonly redirectedAt: Date; @ManyToOne(() => Url, { onDelete: 'CASCADE' }) diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts index 3a1958b..2730ad0 100644 --- a/src/analytics/analytics.module.ts +++ b/src/analytics/analytics.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AnalyticsService } from './analytics.service'; import { UrlAnalytics } from './analytics.entity'; import { AnalyticsController } from './analytics.controller'; +import { AuthModule } from 'src/auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([UrlAnalytics])], + imports: [TypeOrmModule.forFeature([UrlAnalytics]), AuthModule], providers: [AnalyticsService], exports: [], controllers: [AnalyticsController], diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index f2c6eb0..be4b90c 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -58,19 +58,26 @@ export class AnalyticsService { await this.analyticsRepo.save(analytics); } - async getAnalytics(requestData: FilterAnalyticsRequestData) { - const qb = this.analyticsRepo.createQueryBuilder('a'); + async getAnalytics(requestData: FilterAnalyticsRequestData, userId: string) { + const qb = this.analyticsRepo + .createQueryBuilder('a') + .innerJoin('a.url', 'url'); + + qb.andWhere('url.userId=:userId', { userId }); if (requestData.startDate && requestData.endDate) { qb.andWhere('a.redirectedAt BETWEEN :start AND :end', { - start: requestData.startDate, - end: requestData.endDate, + start: new Date(requestData.startDate), + end: new Date(requestData.endDate), }); } if (requestData.browser) { qb.andWhere('a.browser = :browser', { browser: requestData.browser }); } + if (requestData.country) { + qb.andWhere('a.country = :country', { country: requestData.country }); + } if (requestData.device) { qb.andWhere('a.device = :device', { device: requestData.device }); diff --git a/src/analytics/dto/filter-analytics-request-data.ts b/src/analytics/dto/filter-analytics-request-data.ts index f85adc1..709a1b3 100644 --- a/src/analytics/dto/filter-analytics-request-data.ts +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -1,4 +1,5 @@ import { IsDate, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; export class FilterAnalyticsRequestData { @IsOptional() @@ -23,15 +24,12 @@ export class FilterAnalyticsRequestData { ip?: string; @IsOptional() - @IsDate({ message: 'Redirected date must be a valid date' }) - redirectedAt?: Date; - - @IsOptional() - @IsDate({ message: 'Starting date must be a valid date' }) - @IsOptional() + @Type(() => Date) + @IsDate({ message: 'Invalid start date' }) startDate?: Date; - @IsDate({ message: 'End date must be a valid date' }) @IsOptional() + @Type(() => Date) + @IsDate({ message: 'Invalid end date' }) endDate?: Date; } From 93bf6ba1e9d7ac9c0db0668e5e63a090dc871389 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Fri, 31 Oct 2025 09:48:58 +0545 Subject: [PATCH 07/13] fix: renamed timestampz to timestamp with time zone --- src/analytics/analytics.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analytics/analytics.entity.ts b/src/analytics/analytics.entity.ts index 6d51c64..09204a3 100644 --- a/src/analytics/analytics.entity.ts +++ b/src/analytics/analytics.entity.ts @@ -34,7 +34,7 @@ export class UrlAnalytics { @Column({ type: 'varchar', length: 255, nullable: true, name: 'user_agent' }) readonly userAgent: string; - @CreateDateColumn({ type: 'timestamptz', name: 'redirected_at' }) + @CreateDateColumn({ type: 'timestamp with time zone', name: 'redirected_at' }) readonly redirectedAt: Date; @ManyToOne(() => Url, { onDelete: 'CASCADE' }) From d3ebe52f3a02468863dd3607cc701e8eeb1ca90c Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Fri, 31 Oct 2025 09:53:13 +0545 Subject: [PATCH 08/13] re added not null in url_id in createanalytics table --- src/migrations/1761545874535-createAnalyticsTable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/1761545874535-createAnalyticsTable.ts b/src/migrations/1761545874535-createAnalyticsTable.ts index db283f8..14595f0 100644 --- a/src/migrations/1761545874535-createAnalyticsTable.ts +++ b/src/migrations/1761545874535-createAnalyticsTable.ts @@ -5,7 +5,7 @@ export class CreateAnalyticsTable1761545874535 implements MigrationInterface { await queryRunner.query(` CREATE TABLE "url_analytics" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "url_id" uuid , + "url_id" uuid NOT NULL, "redirected_at" timestamp with time zone DEFAULT NOW(), "ip" VARCHAR(100) , "country" VARCHAR(40) , From b7c8d8f98e7abfc5c9b8e3ecbb61236ac67911c3 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Fri, 31 Oct 2025 09:58:30 +0545 Subject: [PATCH 09/13] fix: add deleted_at column in user table --- src/analytics/analytics.entity.ts | 8 ++++++++ src/migrations/1760416146118-createUserTable.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/analytics/analytics.entity.ts b/src/analytics/analytics.entity.ts index 09204a3..2565ff3 100644 --- a/src/analytics/analytics.entity.ts +++ b/src/analytics/analytics.entity.ts @@ -5,6 +5,7 @@ import { ManyToOne, JoinColumn, CreateDateColumn, + DeleteDateColumn, } from 'typeorm'; import { Url } from 'src/url/url.entity'; @@ -37,6 +38,13 @@ export class UrlAnalytics { @CreateDateColumn({ type: 'timestamp with time zone', name: 'redirected_at' }) readonly redirectedAt: Date; + @DeleteDateColumn({ + type: 'timestamp with time zone', + nullable: true, + name: 'deleted_at', + }) + readonly deletedAt?: Date | null; + @ManyToOne(() => Url, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'url_id' }) readonly url: Url; diff --git a/src/migrations/1760416146118-createUserTable.ts b/src/migrations/1760416146118-createUserTable.ts index bb0b50f..e646b2b 100644 --- a/src/migrations/1760416146118-createUserTable.ts +++ b/src/migrations/1760416146118-createUserTable.ts @@ -11,7 +11,8 @@ export class CreateUsersTable1760500000000 implements MigrationInterface { "password" varchar NOT NULL, "verified_at" timestamp with time zone DEFAULT NULL, "created_at" timestamp with time zone DEFAULT now(), - "last_login_at" timestamp with time zone DEFAULT NULL + "last_login_at" timestamp with time zone DEFAULT NULL, + "deleted_at" timestamp with time zone DEFAULT NULL ); `); } From dd3a2356cc242e5fcebb1cb154111d8f57633fd0 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Fri, 31 Oct 2025 10:01:19 +0545 Subject: [PATCH 10/13] fix: add deleted at in user entity --- src/analytics/analytics.entity.ts | 8 -------- src/user/user.entity.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/analytics/analytics.entity.ts b/src/analytics/analytics.entity.ts index 2565ff3..09204a3 100644 --- a/src/analytics/analytics.entity.ts +++ b/src/analytics/analytics.entity.ts @@ -5,7 +5,6 @@ import { ManyToOne, JoinColumn, CreateDateColumn, - DeleteDateColumn, } from 'typeorm'; import { Url } from 'src/url/url.entity'; @@ -38,13 +37,6 @@ export class UrlAnalytics { @CreateDateColumn({ type: 'timestamp with time zone', name: 'redirected_at' }) readonly redirectedAt: Date; - @DeleteDateColumn({ - type: 'timestamp with time zone', - nullable: true, - name: 'deleted_at', - }) - readonly deletedAt?: Date | null; - @ManyToOne(() => Url, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'url_id' }) readonly url: Url; diff --git a/src/user/user.entity.ts b/src/user/user.entity.ts index 3d0b357..a51c268 100644 --- a/src/user/user.entity.ts +++ b/src/user/user.entity.ts @@ -3,6 +3,7 @@ import { PrimaryGeneratedColumn, Column, CreateDateColumn, + DeleteDateColumn, } from 'typeorm'; @Entity({ name: 'users' }) @@ -36,6 +37,13 @@ export class User { }) readonly createdAt: Date; + @DeleteDateColumn({ + type: 'timestamp with time zone', + nullable: true, + name: 'deleted_at', + }) + readonly deletedAt?: Date | null; + @Column({ type: 'timestamp with time zone', nullable: true, From 71b430fd865492888ff35ff1bf7d80cf88d079da Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Sat, 1 Nov 2025 11:56:59 +0545 Subject: [PATCH 11/13] fIX: fixed filter url analytics by start and end date --- src/analytics/analytics.service.ts | 8 +++- .../dto/filter-analytics-request-data.ts | 37 ++++++++++++++++--- src/user/user.service.ts | 2 - 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index be4b90c..a35a5cb 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -67,9 +67,13 @@ export class AnalyticsService { if (requestData.startDate && requestData.endDate) { qb.andWhere('a.redirectedAt BETWEEN :start AND :end', { - start: new Date(requestData.startDate), - end: new Date(requestData.endDate), + start: requestData.startDate, + end: requestData.endDate, }); + } else if (requestData.startDate) { + qb.andWhere('a.redirectedAt >= :start', { start: requestData.startDate }); + } else if (requestData.endDate) { + qb.andWhere('a.redirectedAt <= :end', { end: requestData.endDate }); } if (requestData.browser) { diff --git a/src/analytics/dto/filter-analytics-request-data.ts b/src/analytics/dto/filter-analytics-request-data.ts index 709a1b3..fe3ff01 100644 --- a/src/analytics/dto/filter-analytics-request-data.ts +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -1,5 +1,5 @@ -import { IsDate, IsOptional } from 'class-validator'; -import { Type } from 'class-transformer'; +import { IsNotEmpty, IsOptional } from 'class-validator'; +import { Transform } from 'class-transformer'; export class FilterAnalyticsRequestData { @IsOptional() @@ -24,12 +24,37 @@ export class FilterAnalyticsRequestData { ip?: string; @IsOptional() - @Type(() => Date) - @IsDate({ message: 'Invalid start date' }) + @Transform(({ value }: { value: unknown }): Date | undefined => { + if (typeof value === 'string') { + const normalized = value + .replace(/\s{2,}/g, ' ') + .replace(' 00:00', '+00:00'); + return new Date(normalized); + } + + if (value instanceof Date) { + return value; + } + + return undefined; + }) startDate?: Date; @IsOptional() - @Type(() => Date) - @IsDate({ message: 'Invalid end date' }) + @Transform(({ value }: { value: unknown }): Date | undefined => { + if (typeof value === 'string') { + const normalized = value + .replace(/\s{2,}/g, ' ') + .replace(' 00:00', '+00:00'); + return new Date(normalized); + } + + if (value instanceof Date) { + return value; + } + + return undefined; + }) + @IsNotEmpty() endDate?: Date; } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 3a94775..8e06ff1 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -7,14 +7,12 @@ import { InjectRepository } from '@nestjs/typeorm'; import { User } from './user.entity'; import { Repository } from 'typeorm'; import { SignupRequestData } from 'src/auth/dto/signup-user-dto'; -import { JwtService } from '@nestjs/jwt'; @Injectable() export class UserService { constructor( @InjectRepository(User) private readonly userRepository: Repository, - private readonly jwtService: JwtService, ) {} async findAll(): Promise { From 6f779607d31da2bbcbe4935fd21fbe00f5ee9661 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Mon, 3 Nov 2025 19:44:21 +1300 Subject: [PATCH 12/13] FIX: modified get url analytics dto --- .../dto/filter-analytics-request-data.ts | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/src/analytics/dto/filter-analytics-request-data.ts b/src/analytics/dto/filter-analytics-request-data.ts index fe3ff01..4885f6f 100644 --- a/src/analytics/dto/filter-analytics-request-data.ts +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -1,4 +1,9 @@ -import { IsNotEmpty, IsOptional } from 'class-validator'; +import { + IsDateString, + isDateString, + IsNotEmpty, + IsOptional, +} from 'class-validator'; import { Transform } from 'class-transformer'; export class FilterAnalyticsRequestData { @@ -24,37 +29,16 @@ export class FilterAnalyticsRequestData { ip?: string; @IsOptional() - @Transform(({ value }: { value: unknown }): Date | undefined => { - if (typeof value === 'string') { - const normalized = value - .replace(/\s{2,}/g, ' ') - .replace(' 00:00', '+00:00'); - return new Date(normalized); - } - - if (value instanceof Date) { - return value; - } - - return undefined; + @IsDateString() + @Transform(({ value }) => new Date(value).toUTCString(), { + toPlainOnly: true, }) startDate?: Date; @IsOptional() - @Transform(({ value }: { value: unknown }): Date | undefined => { - if (typeof value === 'string') { - const normalized = value - .replace(/\s{2,}/g, ' ') - .replace(' 00:00', '+00:00'); - return new Date(normalized); - } - - if (value instanceof Date) { - return value; - } - - return undefined; + @IsDateString() + @Transform(({ value }) => new Date(value).toUTCString(), { + toPlainOnly: true, }) - @IsNotEmpty() endDate?: Date; } From a8abace397ff7f9766b4194cd15bf63e6ad3f3d5 Mon Sep 17 00:00:00 2001 From: Laxman Rumba Date: Mon, 3 Nov 2025 14:27:29 +0545 Subject: [PATCH 13/13] Addressed pr review changes --- .env.example | 17 ++++++++++++++++ src/analytics/analytics.entity.ts | 16 ++++++++------- src/analytics/analytics.service.ts | 20 ++++++++++++++----- .../dto/filter-analytics-request-data.ts | 18 ++++++++--------- src/auth/email-verification.entity.ts | 2 ++ .../1762158558309-renameIpColumn.ts | 17 ++++++++++++++++ .../1762159175018-relengthUserAgent.ts | 15 ++++++++++++++ src/url/url.entity.ts | 2 ++ src/user/user.entity.ts | 2 ++ 9 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 .env.example create mode 100644 src/migrations/1762158558309-renameIpColumn.ts create mode 100644 src/migrations/1762159175018-relengthUserAgent.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..554fe3a --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +NODE_ENV=development +DB_PORT=5432 +DB_TYPE=postgres +DB_HOST=db +DB_USERNAME= +DB_PASSWORD= +DB_NAME=postgres +DB_LOGGING= +JWT_SECRET= +JWT_VERIFICATION_TOKEN_SECRET= +JWT_VERIFICATION_TOKEN_EXPIRATION_TIME=3600 +EMAIL_CONFIRMATION_URL= +EMAIL_SERVICE= +EMAIL_USER= +EMAIL_PASS= +ENCRYPTION_KEY= +REDIRECT_BASE_URL = \ No newline at end of file diff --git a/src/analytics/analytics.entity.ts b/src/analytics/analytics.entity.ts index 09204a3..fb95a8c 100644 --- a/src/analytics/analytics.entity.ts +++ b/src/analytics/analytics.entity.ts @@ -5,11 +5,13 @@ import { ManyToOne, JoinColumn, CreateDateColumn, + Index, } from 'typeorm'; import { Url } from 'src/url/url.entity'; @Entity({ name: 'url_analytics' }) export class UrlAnalytics { + @Index() @PrimaryGeneratedColumn('uuid') readonly id: string; @@ -17,22 +19,22 @@ export class UrlAnalytics { readonly urlId: string; @Column({ type: 'varchar', length: 40, nullable: true, name: 'country' }) - readonly country: string; + readonly country: string | null; @Column({ type: 'varchar', length: 50, nullable: true, name: 'device' }) - readonly device: string; + readonly device: string | null; @Column({ type: 'varchar', length: 40, nullable: true, name: 'os' }) - readonly os: string; + readonly os: string | null; @Column({ type: 'varchar', length: 40, nullable: true, name: 'browser' }) - readonly browser: string; + readonly browser: string | null; - @Column({ type: 'varchar', length: 100, nullable: true, name: 'ip' }) - readonly ip: string; + @Column({ type: 'varchar', length: 100, nullable: true, name: 'ip_address' }) + readonly ipAddress: string | null; @Column({ type: 'varchar', length: 255, nullable: true, name: 'user_agent' }) - readonly userAgent: string; + readonly userAgent: string | null; @CreateDateColumn({ type: 'timestamp with time zone', name: 'redirected_at' }) readonly redirectedAt: Date; diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index a35a5cb..2295fd5 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -19,7 +19,7 @@ export class AnalyticsService { async recordClick(event: UrlRedirectedEvent): Promise { const urlId = event.urlId; const req = event.req; - const ip = (req.headers['x-forwarded-for'] as string) + const ipAddress = (req.headers['x-forwarded-for'] as string) ?.split(',')[0] ?.trim(); @@ -30,25 +30,31 @@ export class AnalyticsService { } ).parse(userAgent); + // Match the first section inside parentheses of the User-Agent string (up to the first semicolon). + // This typically represents the device or platform, e.g. "Windows NT 10.0" or "iPhone". const deviceMatch = parsed.source.match(/\(([^;]+);/); const device = deviceMatch ? deviceMatch[1] : 'Unknown Device'; + // Look for known browser names followed by a version number, + // e.g. "Chrome/120.0", "Firefox/118.0". const browserMatch = parsed.source.match( /(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/, ); const browser = browserMatch ? browserMatch[0] : 'Unknown Browser'; + // Match the substring inside parentheses that follows the first semicolon. + // For example, from "(Windows NT 10.0; Win64; x64)" → captures "Win64; x64". const osMatch = parsed.source.match(/\((?:[^;]+);\s*([^)]+)\)/); const os = osMatch ? osMatch[1] : 'Unknown OS'; - const geo = geoip.lookup(ip); + const geo = geoip.lookup(ipAddress); const country = geo?.country || 'Unknown'; const analytics = this.analyticsRepo.create({ urlId, os, - ip, + ipAddress, browser: browser, userAgent, device: device, @@ -70,9 +76,13 @@ export class AnalyticsService { start: requestData.startDate, end: requestData.endDate, }); - } else if (requestData.startDate) { + } + + if (requestData.startDate) { qb.andWhere('a.redirectedAt >= :start', { start: requestData.startDate }); - } else if (requestData.endDate) { + } + + if (requestData.endDate) { qb.andWhere('a.redirectedAt <= :end', { end: requestData.endDate }); } diff --git a/src/analytics/dto/filter-analytics-request-data.ts b/src/analytics/dto/filter-analytics-request-data.ts index 4885f6f..6f6bf01 100644 --- a/src/analytics/dto/filter-analytics-request-data.ts +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -8,37 +8,37 @@ import { Transform } from 'class-transformer'; export class FilterAnalyticsRequestData { @IsOptional() - browser?: string; + browser?: string | null; @IsOptional() - device?: string; + device?: string | null; @IsOptional() - groupByUrl?: boolean; + groupByUrl?: boolean | null; @IsOptional() - urlId?: string; + urlId?: string | null; @IsOptional() - os?: string; + os?: string | null; @IsOptional() - country?: string; + country?: string | null; @IsOptional() - ip?: string; + ip?: string | null; @IsOptional() @IsDateString() @Transform(({ value }) => new Date(value).toUTCString(), { toPlainOnly: true, }) - startDate?: Date; + startDate?: Date | null; @IsOptional() @IsDateString() @Transform(({ value }) => new Date(value).toUTCString(), { toPlainOnly: true, }) - endDate?: Date; + endDate?: Date | null; } diff --git a/src/auth/email-verification.entity.ts b/src/auth/email-verification.entity.ts index 848d7f1..1996069 100644 --- a/src/auth/email-verification.entity.ts +++ b/src/auth/email-verification.entity.ts @@ -6,10 +6,12 @@ import { CreateDateColumn, JoinColumn, OneToOne, + Index, } from 'typeorm'; @Entity('email_verifications') export class EmailVerification { + @Index() @PrimaryGeneratedColumn('uuid') readonly id: string; diff --git a/src/migrations/1762158558309-renameIpColumn.ts b/src/migrations/1762158558309-renameIpColumn.ts new file mode 100644 index 0000000..caca28b --- /dev/null +++ b/src/migrations/1762158558309-renameIpColumn.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameIpColumn1762158558309 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + RENAME COLUMN "ip" TO "ip_address"; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + RENAME COLUMN "ip_address" TO "ip"; + `); + } +} diff --git a/src/migrations/1762159175018-relengthUserAgent.ts b/src/migrations/1762159175018-relengthUserAgent.ts new file mode 100644 index 0000000..7a4fcf8 --- /dev/null +++ b/src/migrations/1762159175018-relengthUserAgent.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RelengthUserAgent1762159175018 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + ALTER COLUMN "user_agent" TYPE TEXT;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + ALTER COLUMN "user_agent" TYPE varchar(200);`); + } +} diff --git a/src/url/url.entity.ts b/src/url/url.entity.ts index 3b8241e..b05be9b 100644 --- a/src/url/url.entity.ts +++ b/src/url/url.entity.ts @@ -7,11 +7,13 @@ import { ManyToOne, JoinColumn, DeleteDateColumn, + Index, } from 'typeorm'; import { User } from 'src/user/user.entity'; @Entity({ name: 'urls' }) export class Url { + @Index() @PrimaryGeneratedColumn('uuid') readonly id: string; diff --git a/src/user/user.entity.ts b/src/user/user.entity.ts index a51c268..5bc0968 100644 --- a/src/user/user.entity.ts +++ b/src/user/user.entity.ts @@ -4,10 +4,12 @@ import { Column, CreateDateColumn, DeleteDateColumn, + Index, } from 'typeorm'; @Entity({ name: 'users' }) export class User { + @Index() @PrimaryGeneratedColumn('uuid') readonly id: string;