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.controller.ts b/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..9a301c4 --- /dev/null +++ b/src/analytics/analytics.controller.ts @@ -0,0 +1,30 @@ +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) {} + + @UseGuards(AuthGuard) + @HttpCode(HttpStatus.OK) + @Get() + 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 5091b69..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,19 +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 | 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.module.ts b/src/analytics/analytics.module.ts index 11fba08..2730ad0 100644 --- a/src/analytics/analytics.module.ts +++ b/src/analytics/analytics.module.ts @@ -2,11 +2,13 @@ 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'; +import { AuthModule } from 'src/auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([UrlAnalytics])], + imports: [TypeOrmModule.forFeature([UrlAnalytics]), AuthModule], providers: [AnalyticsService], exports: [], - controllers: [], + controllers: [AnalyticsController], }) export class AnalyticsModule {} diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index dfdd94a..2295fd5 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( @@ -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 ipAddress = (req.headers['x-forwarded-for'] as string) + ?.split(',')[0] + ?.trim(); const userAgent = req.headers['user-agent'] || ''; const parsed = ( @@ -31,15 +30,32 @@ 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'; - const geo = geoip.lookup(ip); + + // 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(ipAddress); const country = geo?.country || 'Unknown'; const analytics = this.analyticsRepo.create({ urlId, - ip, - browser: `${parsed.family} ${parsed.major}.${parsed.minor}.${parsed.patch} `, + os, + ipAddress, + browser: browser, userAgent, device: device, country, @@ -47,4 +63,52 @@ export class AnalyticsService { await this.analyticsRepo.save(analytics); } + + 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, + }); + } + + if (requestData.startDate) { + qb.andWhere('a.redirectedAt >= :start', { start: requestData.startDate }); + } + + if (requestData.endDate) { + qb.andWhere('a.redirectedAt <= :end', { end: 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 }); + } + + if (requestData.os) { + qb.andWhere('a.os = :os', { os: requestData.os }); + } + + if (requestData.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..6f6bf01 --- /dev/null +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -0,0 +1,44 @@ +import { + IsDateString, + isDateString, + IsNotEmpty, + IsOptional, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class FilterAnalyticsRequestData { + @IsOptional() + browser?: string | null; + + @IsOptional() + device?: string | null; + + @IsOptional() + groupByUrl?: boolean | null; + + @IsOptional() + urlId?: string | null; + + @IsOptional() + os?: string | null; + + @IsOptional() + country?: string | null; + + @IsOptional() + ip?: string | null; + + @IsOptional() + @IsDateString() + @Transform(({ value }) => new Date(value).toUTCString(), { + toPlainOnly: true, + }) + startDate?: Date | null; + + @IsOptional() + @IsDateString() + @Transform(({ value }) => new Date(value).toUTCString(), { + toPlainOnly: true, + }) + endDate?: Date | null; +} diff --git a/src/analytics/types.ts b/src/analytics/types.ts index 3205200..abf428c 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -4,10 +4,4 @@ export type ParsedUserAgent = { minor: string; patch: string; source: string; - device: { - family: string; - major: string; - minor: string; - patch: string; - }; }; 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/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/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/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 ); `); } diff --git a/src/migrations/1761545874535-createAnalyticsTable.ts b/src/migrations/1761545874535-createAnalyticsTable.ts index bdeebc2..14595f0 100644 --- a/src/migrations/1761545874535-createAnalyticsTable.ts +++ b/src/migrations/1761545874535-createAnalyticsTable.ts @@ -7,11 +7,12 @@ export class CreateAnalyticsTable1761545874535 implements MigrationInterface { "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "url_id" uuid NOT NULL, "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/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.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..b05be9b 100644 --- a/src/url/url.entity.ts +++ b/src/url/url.entity.ts @@ -6,11 +6,14 @@ import { UpdateDateColumn, 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; @@ -40,7 +43,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'); } diff --git a/src/user/user.entity.ts b/src/user/user.entity.ts index 3d0b357..5bc0968 100644 --- a/src/user/user.entity.ts +++ b/src/user/user.entity.ts @@ -3,10 +3,13 @@ import { PrimaryGeneratedColumn, Column, CreateDateColumn, + DeleteDateColumn, + Index, } from 'typeorm'; @Entity({ name: 'users' }) export class User { + @Index() @PrimaryGeneratedColumn('uuid') readonly id: string; @@ -36,6 +39,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, 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 {