Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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 =
30 changes: 30 additions & 0 deletions src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
17 changes: 11 additions & 6 deletions src/analytics/analytics.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,36 @@ 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;

@Column({ type: 'uuid', name: 'url_id' })
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;
Expand Down
6 changes: 4 additions & 2 deletions src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
80 changes: 72 additions & 8 deletions src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand All @@ -19,10 +19,9 @@ export class AnalyticsService {
async recordClick(event: UrlRedirectedEvent): Promise<void> {
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 = (
Expand All @@ -31,20 +30,85 @@ 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(/\(([^;]+);/);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does that regex expression do? It's better to add in comment about it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

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,
});

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();
}
}
44 changes: 44 additions & 0 deletions src/analytics/dto/filter-analytics-request-data.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 0 additions & 6 deletions src/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,4 @@ export type ParsedUserAgent = {
minor: string;
patch: string;
source: string;
device: {
family: string;
major: string;
minor: string;
patch: string;
};
};
2 changes: 2 additions & 0 deletions src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 2 additions & 0 deletions src/auth/email-verification.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
CreateDateColumn,
JoinColumn,
OneToOne,
Index,
} from 'typeorm';

@Entity('email_verifications')
export class EmailVerification {
@Index()
@PrimaryGeneratedColumn('uuid')
readonly id: string;

Expand Down
22 changes: 0 additions & 22 deletions src/migrations/1760416146118-createTable.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/migrations/1760416146118-createUserTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
`);
}
Expand Down
11 changes: 6 additions & 5 deletions src/migrations/1761545874535-createAnalyticsTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user agents might exceed 200 character limit

CONSTRAINT "fk_url_analytics"
FOREIGN KEY ("url_id") REFERENCES "urls" ("id") ON DELETE CASCADE
);
Expand Down
17 changes: 17 additions & 0 deletions src/migrations/1762158558309-renameIpColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class RenameIpColumn1762158558309 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "url_analytics"
RENAME COLUMN "ip" TO "ip_address";
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "url_analytics"
RENAME COLUMN "ip_address" TO "ip";
`);
}
}
15 changes: 15 additions & 0 deletions src/migrations/1762159175018-relengthUserAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class RelengthUserAgent1762159175018 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "url_analytics"
ALTER COLUMN "user_agent" TYPE TEXT;`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "url_analytics"
ALTER COLUMN "user_agent" TYPE varchar(200);`);
}
}
Loading