Skip to content

Commit 3e8d1a3

Browse files
authored
Merge pull request #15 from pagevamp/feat-analytics-filter
Feat analytics filter
2 parents d556a76 + a8abace commit 3e8d1a3

19 files changed

+238
-59
lines changed

.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
NODE_ENV=development
2+
DB_PORT=5432
3+
DB_TYPE=postgres
4+
DB_HOST=db
5+
DB_USERNAME=
6+
DB_PASSWORD=
7+
DB_NAME=postgres
8+
DB_LOGGING=
9+
JWT_SECRET=
10+
JWT_VERIFICATION_TOKEN_SECRET=
11+
JWT_VERIFICATION_TOKEN_EXPIRATION_TIME=3600
12+
EMAIL_CONFIRMATION_URL=
13+
EMAIL_SERVICE=
14+
EMAIL_USER=
15+
EMAIL_PASS=
16+
ENCRYPTION_KEY=
17+
REDIRECT_BASE_URL =
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
Body,
3+
Controller,
4+
Get,
5+
HttpCode,
6+
HttpStatus,
7+
Query,
8+
Req,
9+
UseGuards,
10+
} from '@nestjs/common';
11+
import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data';
12+
import { AnalyticsService } from './analytics.service';
13+
import { AuthGuard } from 'src/auth/auth.guard';
14+
import type { RequestWithUser } from 'src/types/RequestWithUser';
15+
16+
@Controller('analytics')
17+
export class AnalyticsController {
18+
constructor(private readonly analyticsService: AnalyticsService) {}
19+
20+
@UseGuards(AuthGuard)
21+
@HttpCode(HttpStatus.OK)
22+
@Get()
23+
async filterUrlAnalytics(
24+
@Query() query: FilterAnalyticsRequestData,
25+
@Req() request: RequestWithUser,
26+
) {
27+
const userId = request.decodedData.sub;
28+
return this.analyticsService.getAnalytics(query, userId);
29+
}
30+
}

src/analytics/analytics.entity.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,36 @@ import {
55
ManyToOne,
66
JoinColumn,
77
CreateDateColumn,
8+
Index,
89
} from 'typeorm';
910
import { Url } from 'src/url/url.entity';
1011

1112
@Entity({ name: 'url_analytics' })
1213
export class UrlAnalytics {
14+
@Index()
1315
@PrimaryGeneratedColumn('uuid')
1416
readonly id: string;
1517

1618
@Column({ type: 'uuid', name: 'url_id' })
1719
readonly urlId: string;
1820

1921
@Column({ type: 'varchar', length: 40, nullable: true, name: 'country' })
20-
readonly country: string;
22+
readonly country: string | null;
2123

2224
@Column({ type: 'varchar', length: 50, nullable: true, name: 'device' })
23-
readonly device: string;
25+
readonly device: string | null;
26+
27+
@Column({ type: 'varchar', length: 40, nullable: true, name: 'os' })
28+
readonly os: string | null;
2429

2530
@Column({ type: 'varchar', length: 40, nullable: true, name: 'browser' })
26-
readonly browser: string;
31+
readonly browser: string | null;
2732

28-
@Column({ type: 'varchar', length: 100, nullable: true, name: 'ip' })
29-
readonly ip: string;
33+
@Column({ type: 'varchar', length: 100, nullable: true, name: 'ip_address' })
34+
readonly ipAddress: string | null;
3035

3136
@Column({ type: 'varchar', length: 255, nullable: true, name: 'user_agent' })
32-
readonly userAgent: string;
37+
readonly userAgent: string | null;
3338

3439
@CreateDateColumn({ type: 'timestamp with time zone', name: 'redirected_at' })
3540
readonly redirectedAt: Date;

src/analytics/analytics.module.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33
import { AnalyticsService } from './analytics.service';
44
import { UrlAnalytics } from './analytics.entity';
5+
import { AnalyticsController } from './analytics.controller';
6+
import { AuthModule } from 'src/auth/auth.module';
57

68
@Module({
7-
imports: [TypeOrmModule.forFeature([UrlAnalytics])],
9+
imports: [TypeOrmModule.forFeature([UrlAnalytics]), AuthModule],
810
providers: [AnalyticsService],
911
exports: [],
10-
controllers: [],
12+
controllers: [AnalyticsController],
1113
})
1214
export class AnalyticsModule {}

src/analytics/analytics.service.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Injectable } from '@nestjs/common';
2-
import { Request } from 'express';
32
import geoip from 'geoip-lite';
43
import { UrlAnalytics } from './analytics.entity';
54
import { InjectRepository } from '@nestjs/typeorm';
@@ -8,6 +7,7 @@ import useragent from 'useragent';
87
import { ParsedUserAgent } from './types';
98
import { OnEvent } from '@nestjs/event-emitter';
109
import { UrlRedirectedEvent } from 'src/event/Url-redirected.events';
10+
import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data';
1111
@Injectable()
1212
export class AnalyticsService {
1313
constructor(
@@ -19,10 +19,9 @@ export class AnalyticsService {
1919
async recordClick(event: UrlRedirectedEvent): Promise<void> {
2020
const urlId = event.urlId;
2121
const req = event.req;
22-
const ip =
23-
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
24-
req.socket?.remoteAddress?.replace('::ffff:', '') ||
25-
'0.0.0.0';
22+
const ipAddress = (req.headers['x-forwarded-for'] as string)
23+
?.split(',')[0]
24+
?.trim();
2625

2726
const userAgent = req.headers['user-agent'] || '';
2827
const parsed = (
@@ -31,20 +30,85 @@ export class AnalyticsService {
3130
}
3231
).parse(userAgent);
3332

33+
// Match the first section inside parentheses of the User-Agent string (up to the first semicolon).
34+
// This typically represents the device or platform, e.g. "Windows NT 10.0" or "iPhone".
3435
const deviceMatch = parsed.source.match(/\(([^;]+);/);
3536
const device = deviceMatch ? deviceMatch[1] : 'Unknown Device';
36-
const geo = geoip.lookup(ip);
37+
38+
// Look for known browser names followed by a version number,
39+
// e.g. "Chrome/120.0", "Firefox/118.0".
40+
const browserMatch = parsed.source.match(
41+
/(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/,
42+
);
43+
const browser = browserMatch ? browserMatch[0] : 'Unknown Browser';
44+
45+
// Match the substring inside parentheses that follows the first semicolon.
46+
// For example, from "(Windows NT 10.0; Win64; x64)" → captures "Win64; x64".
47+
const osMatch = parsed.source.match(/\((?:[^;]+);\s*([^)]+)\)/);
48+
49+
const os = osMatch ? osMatch[1] : 'Unknown OS';
50+
51+
const geo = geoip.lookup(ipAddress);
3752
const country = geo?.country || 'Unknown';
3853

3954
const analytics = this.analyticsRepo.create({
4055
urlId,
41-
ip,
42-
browser: `${parsed.family} ${parsed.major}.${parsed.minor}.${parsed.patch} `,
56+
os,
57+
ipAddress,
58+
browser: browser,
4359
userAgent,
4460
device: device,
4561
country,
4662
});
4763

4864
await this.analyticsRepo.save(analytics);
4965
}
66+
67+
async getAnalytics(requestData: FilterAnalyticsRequestData, userId: string) {
68+
const qb = this.analyticsRepo
69+
.createQueryBuilder('a')
70+
.innerJoin('a.url', 'url');
71+
72+
qb.andWhere('url.userId=:userId', { userId });
73+
74+
if (requestData.startDate && requestData.endDate) {
75+
qb.andWhere('a.redirectedAt BETWEEN :start AND :end', {
76+
start: requestData.startDate,
77+
end: requestData.endDate,
78+
});
79+
}
80+
81+
if (requestData.startDate) {
82+
qb.andWhere('a.redirectedAt >= :start', { start: requestData.startDate });
83+
}
84+
85+
if (requestData.endDate) {
86+
qb.andWhere('a.redirectedAt <= :end', { end: requestData.endDate });
87+
}
88+
89+
if (requestData.browser) {
90+
qb.andWhere('a.browser = :browser', { browser: requestData.browser });
91+
}
92+
if (requestData.country) {
93+
qb.andWhere('a.country = :country', { country: requestData.country });
94+
}
95+
96+
if (requestData.device) {
97+
qb.andWhere('a.device = :device', { device: requestData.device });
98+
}
99+
100+
if (requestData.os) {
101+
qb.andWhere('a.os = :os', { os: requestData.os });
102+
}
103+
104+
if (requestData.groupByUrl) {
105+
qb.select('a.url', 'url')
106+
.addSelect('COUNT(*)', 'hits')
107+
.groupBy('a.url')
108+
.orderBy('hits', 'DESC');
109+
return qb.getRawMany();
110+
}
111+
112+
return qb.getMany();
113+
}
50114
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
IsDateString,
3+
isDateString,
4+
IsNotEmpty,
5+
IsOptional,
6+
} from 'class-validator';
7+
import { Transform } from 'class-transformer';
8+
9+
export class FilterAnalyticsRequestData {
10+
@IsOptional()
11+
browser?: string | null;
12+
13+
@IsOptional()
14+
device?: string | null;
15+
16+
@IsOptional()
17+
groupByUrl?: boolean | null;
18+
19+
@IsOptional()
20+
urlId?: string | null;
21+
22+
@IsOptional()
23+
os?: string | null;
24+
25+
@IsOptional()
26+
country?: string | null;
27+
28+
@IsOptional()
29+
ip?: string | null;
30+
31+
@IsOptional()
32+
@IsDateString()
33+
@Transform(({ value }) => new Date(value).toUTCString(), {
34+
toPlainOnly: true,
35+
})
36+
startDate?: Date | null;
37+
38+
@IsOptional()
39+
@IsDateString()
40+
@Transform(({ value }) => new Date(value).toUTCString(), {
41+
toPlainOnly: true,
42+
})
43+
endDate?: Date | null;
44+
}

src/analytics/types.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,4 @@ export type ParsedUserAgent = {
44
minor: string;
55
patch: string;
66
source: string;
7-
device: {
8-
family: string;
9-
major: string;
10-
minor: string;
11-
patch: string;
12-
};
137
};

src/auth/auth.guard.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ export class AuthGuard implements CanActivate {
2525
const decoded = await this.authService.validateToken(token);
2626
request.decodedData = decoded;
2727
const userData = request.decodedData;
28+
2829
if (!userData) {
2930
throw new UnauthorizedException('Invalid or missing token');
3031
}
32+
3133
return true;
3234
}
3335
}

src/auth/email-verification.entity.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import {
66
CreateDateColumn,
77
JoinColumn,
88
OneToOne,
9+
Index,
910
} from 'typeorm';
1011

1112
@Entity('email_verifications')
1213
export class EmailVerification {
14+
@Index()
1315
@PrimaryGeneratedColumn('uuid')
1416
readonly id: string;
1517

src/migrations/1760416146118-createTable.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

0 commit comments

Comments
 (0)