Skip to content

Commit 255f3dd

Browse files
committed
merged feat-url into feat-throttler
2 parents 1788476 + d658f7e commit 255f3dd

23 files changed

+303
-45
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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
1-
import { Controller } from '@nestjs/common';
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';
212
import { AnalyticsService } from './analytics.service';
13+
import type { RequestWithUser } from 'src/types/RequestWithUser';
14+
import { GuardService } from 'src/guard/guard.service';
315

416
@Controller('analytics')
517
export class AnalyticsController {
618
constructor(private readonly analyticsService: AnalyticsService) {}
19+
20+
@UseGuards(GuardService)
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+
}
730
}

src/analytics/analytics.entity.ts

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

12+
@Index('IDX_url_analytics_url_id', ['urlId'])
13+
@Index('IDX_url_analytics_redirected_at', ['redirectedAt'])
14+
@Index('IDX_url_analytics_url_id_redirected_at', ['urlId', 'redirectedAt'])
1115
@Entity({ name: 'url_analytics' })
1216
export class UrlAnalytics {
1317
@PrimaryGeneratedColumn('uuid')
@@ -17,19 +21,22 @@ export class UrlAnalytics {
1721
readonly urlId: string;
1822

1923
@Column({ type: 'varchar', length: 40, nullable: true, name: 'country' })
20-
readonly country: string;
24+
readonly country?: string | null;
2125

2226
@Column({ type: 'varchar', length: 50, nullable: true, name: 'device' })
23-
readonly device: string;
27+
readonly device?: string | null;
28+
29+
@Column({ type: 'varchar', length: 40, nullable: true, name: 'os' })
30+
readonly os?: string | null;
2431

2532
@Column({ type: 'varchar', length: 40, nullable: true, name: 'browser' })
26-
readonly browser: string;
33+
readonly browser?: string | null;
2734

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

3138
@Column({ type: 'varchar', length: 255, nullable: true, name: 'user_agent' })
32-
readonly userAgent: string;
39+
readonly userAgent?: string | null;
3340

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

src/analytics/analytics.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { AuthModule } from 'src/auth/auth.module';
88
@Module({
99
imports: [TypeOrmModule.forFeature([UrlAnalytics]), AuthModule],
1010
providers: [AnalyticsService],
11-
exports: [AnalyticsService],
11+
exports: [],
1212
controllers: [AnalyticsController],
1313
})
1414
export class AnalyticsModule {}

src/analytics/analytics.service.ts

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
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';
65
import { Repository } from 'typeorm';
76
import useragent from 'useragent';
87
import { ParsedUserAgent } from './types';
8+
import { OnEvent } from '@nestjs/event-emitter';
9+
import { UrlRedirectedEvent } from 'src/event/Url-redirected.events';
10+
import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data';
911
@Injectable()
1012
export class AnalyticsService {
1113
constructor(
1214
@InjectRepository(UrlAnalytics)
1315
private readonly analyticsRepo: Repository<UrlAnalytics>,
1416
) {}
1517

16-
async recordClick(urlId: string, req: Request): Promise<void> {
17-
const ip =
18-
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
19-
req.socket?.remoteAddress?.replace('::ffff:', '') ||
20-
'0.0.0.0';
18+
@OnEvent('url.redirected')
19+
async recordClick(event: UrlRedirectedEvent): Promise<void> {
20+
const urlId = event.urlId;
21+
const req = event.req;
22+
const ipAddress = (req.headers['x-forwarded-for'] as string)
23+
?.split(',')[0]
24+
?.trim();
2125

2226
const userAgent = req.headers['user-agent'] || '';
2327
const parsed = (
@@ -26,19 +30,31 @@ export class AnalyticsService {
2630
}
2731
).parse(userAgent);
2832

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".
2935
const deviceMatch = parsed.source.match(/\(([^;]+);/);
3036
const device = deviceMatch ? deviceMatch[1] : 'Unknown Device';
3137

38+
// Look for known browser names followed by a version number,
39+
// e.g. "Chrome/120.0", "Firefox/118.0".
3240
const browserMatch = parsed.source.match(
3341
/(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/,
3442
);
3543
const browser = browserMatch ? browserMatch[0] : 'Unknown Browser';
3644

37-
const geo = geoip.lookup(ip);
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);
3852
const country = geo?.country || 'Unknown';
53+
3954
const analytics = this.analyticsRepo.create({
4055
urlId,
41-
ip,
56+
os,
57+
ipAddress,
4258
browser: browser,
4359
userAgent,
4460
device: device,
@@ -47,4 +63,57 @@ export class AnalyticsService {
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+
.take(10)
72+
.skip(10);
73+
74+
qb.andWhere('url.userId=:userId', { userId });
75+
76+
const start = requestData.startDate || new Date(0);
77+
const end = requestData.endDate || new Date();
78+
79+
qb.andWhere('a.redirectedAt BETWEEN :start AND :end', {
80+
start,
81+
end,
82+
});
83+
84+
if (requestData.browser) {
85+
qb.andWhere('a.browser = :browser', { browser: requestData.browser });
86+
}
87+
if (requestData.country) {
88+
qb.andWhere('a.country = :country', { country: requestData.country });
89+
}
90+
91+
if (requestData.device) {
92+
qb.andWhere('a.device = :device', { device: requestData.device });
93+
}
94+
95+
if (requestData.os) {
96+
qb.andWhere('a.os = :os', { os: requestData.os });
97+
}
98+
99+
const groupColumns: string[] = [];
100+
101+
if (requestData.groupByUrl) groupColumns.push('a.url_id');
102+
if (requestData.groupByDevice) groupColumns.push('a.device');
103+
if (requestData.groupByOs) groupColumns.push('a.os');
104+
if (requestData.groupByBrowser) groupColumns.push('a.browser');
105+
if (requestData.groupByCountry) groupColumns.push('a.country');
106+
if (requestData.groupByIpAddress) groupColumns.push('a.ip_address');
107+
108+
if (groupColumns.length > 0) {
109+
qb.select(groupColumns.join(', '))
110+
.addSelect('COUNT(*)', 'hits')
111+
.groupBy(groupColumns.join(', '))
112+
.orderBy('hits', 'DESC');
113+
114+
return qb.getRawMany();
115+
}
116+
117+
return await qb.getMany();
118+
}
50119
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { IsDateString, IsOptional } from 'class-validator';
2+
import { Transform } from 'class-transformer';
3+
4+
export class FilterAnalyticsRequestData {
5+
@IsOptional()
6+
browser?: string | null;
7+
8+
@IsOptional()
9+
device?: string | null;
10+
11+
@IsOptional()
12+
groupByUrl?: boolean | null;
13+
14+
@IsOptional()
15+
groupByDevice?: boolean | null;
16+
17+
@IsOptional()
18+
groupByIpAddress?: boolean | null;
19+
20+
@IsOptional()
21+
groupByOs?: boolean | null;
22+
23+
@IsOptional()
24+
groupByCountry?: boolean | null;
25+
26+
@IsOptional()
27+
groupByBrowser?: boolean | null;
28+
29+
@IsOptional()
30+
urlId?: string | null;
31+
32+
@IsOptional()
33+
os?: string | null;
34+
35+
@IsOptional()
36+
country?: string | null;
37+
38+
@IsOptional()
39+
ip?: string | null;
40+
41+
@IsOptional()
42+
@IsDateString()
43+
@Transform(({ value }) => new Date(value).toUTCString(), {
44+
toPlainOnly: true,
45+
})
46+
startDate?: Date | null;
47+
48+
@IsOptional()
49+
@IsDateString()
50+
@Transform(({ value }) => new Date(value).toUTCString(), {
51+
toPlainOnly: true,
52+
})
53+
endDate?: Date | null;
54+
}

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import { CronModule } from './cron/cron.module';
1111
import { AnalyticsModule } from './analytics/analytics.module';
1212
import { GuardService } from './guard/guard.service';
1313
import { GuardModule } from './guard/guard.module';
14+
import { EventEmitterModule } from '@nestjs/event-emitter';
1415

1516
@Module({
1617
imports: [
1718
ScheduleModule.forRoot(),
19+
EventEmitterModule.forRoot(),
1820
TypeOrmModule.forRoot(dataSource.options),
1921
UserModule,
2022
AuthModule,

src/auth/auth.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export class AuthService {
5858
throw new BadRequestException('User is already verified');
5959
}
6060

61+
await this.emailVerificationRepo.delete({
62+
userId: user.id,
63+
});
64+
6165
const payload: EmailVerificationPayload = {
6266
email,
6367
};

src/auth/email-verification.entity.ts

Lines changed: 3 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

@@ -22,6 +24,7 @@ export class EmailVerification {
2224
@JoinColumn({ name: 'user_id' })
2325
readonly user: User;
2426

27+
@Index('IDX_email_verifications_token')
2528
@Column({ length: 255 })
2629
readonly token: string;
2730

src/event/Url-redirected.events.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { RequestWithUser } from 'src/types/RequestWithUser';
2+
3+
export class UrlRedirectedEvent {
4+
constructor(
5+
public readonly urlId: string,
6+
public readonly req: RequestWithUser,
7+
) {}
8+
}

0 commit comments

Comments
 (0)