Akshar2325 commited on
Commit
2f0bbdc
·
1 Parent(s): bcd6afe

feat(short-url): implement Shlink URL shortener integration with CRUD operations

Browse files
.env.example CHANGED
@@ -211,3 +211,9 @@ N8N_DISCORD_LOOGING_WEBHOOK_SUCCESS_URL=https://your-n8n-instance.com/webhook/ap
211
 
212
  # Webhook for error API calls (status 400+)
213
  N8N_DISCORD_LOOGING_WEBHOOK_ERROR_URL=https://your-n8n-instance.com/webhook/api-error-log
 
 
 
 
 
 
 
211
 
212
  # Webhook for error API calls (status 400+)
213
  N8N_DISCORD_LOOGING_WEBHOOK_ERROR_URL=https://your-n8n-instance.com/webhook/api-error-log
214
+
215
+ # Shlink URL Shortener Configuration
216
+ # ===========================
217
+ # Shlink instance URL and API key for URL shortening service
218
+ SHLINK_URL=https://sasuke734-shlink-shortener.hf.space
219
+ SHLINK_API_KEY=33707705d3fc4449b5b468fe4829d6c95cee1e70889a45f39d265a1b654c860a7927ea0b6d234181b46f1cb8232d791f611c35bf681c415895e
prisma/schema.prisma CHANGED
@@ -81,6 +81,10 @@ enum PLATFORM_TYPE {
81
  WEB
82
  }
83
 
 
 
 
 
84
  // ============ USER MANAGEMENT ============
85
 
86
  model User {
@@ -929,3 +933,19 @@ model SeriesTag {
929
  @@index([tagId])
930
  @@map("series_tags")
931
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  WEB
82
  }
83
 
84
+ enum URL_SHORTENER_PROVIDER {
85
+ SHLINK
86
+ }
87
+
88
  // ============ USER MANAGEMENT ============
89
 
90
  model User {
 
933
  @@index([tagId])
934
  @@map("series_tags")
935
  }
936
+
937
+ // ============ SHORT URL ============
938
+
939
+ model ShortUrl {
940
+ id String @id @default(uuid())
941
+ longUrl String @db.Text
942
+ shortUrl String
943
+ shortCode String?
944
+ provider URL_SHORTENER_PROVIDER
945
+ createdAt DateTime @default(now()) @db.Timestamp(3)
946
+ updatedAt DateTime @updatedAt @db.Timestamp(3)
947
+ isDeleted Boolean @default(false)
948
+
949
+ @@index([provider])
950
+ @@map("short_urls")
951
+ }
src/app.module.ts CHANGED
@@ -9,6 +9,7 @@ import { CommonModule } from './shared/modules/common/common.module';
9
  import { DiscordLoggerModule } from './shared/modules/discord-logger/discord-logger.module';
10
  import { BaseQueryCoreModule } from './core/base-query-core/base-query-core.module';
11
  import { UploadModule } from './shared/modules/upload/upload.module';
 
12
  import { AppController } from './app.controller';
13
  import { ApiTokenGuard } from './shared/guards/api-token.guard';
14
  import { DiscordLoggerInterceptor } from './shared/modules/discord-logger/discord-logger.interceptor';
@@ -31,6 +32,7 @@ import { DiscordExceptionFilter } from './shared/modules/discord-logger/discord-
31
  DiscordLoggerModule,
32
  BaseQueryCoreModule,
33
  UploadModule,
 
34
  UserModule,
35
  SuperAdminModule,
36
  ],
 
9
  import { DiscordLoggerModule } from './shared/modules/discord-logger/discord-logger.module';
10
  import { BaseQueryCoreModule } from './core/base-query-core/base-query-core.module';
11
  import { UploadModule } from './shared/modules/upload/upload.module';
12
+ import { ShortUrlModule } from './shared/modules/short-url/short-url.module';
13
  import { AppController } from './app.controller';
14
  import { ApiTokenGuard } from './shared/guards/api-token.guard';
15
  import { DiscordLoggerInterceptor } from './shared/modules/discord-logger/discord-logger.interceptor';
 
32
  DiscordLoggerModule,
33
  BaseQueryCoreModule,
34
  UploadModule,
35
+ ShortUrlModule,
36
  UserModule,
37
  SuperAdminModule,
38
  ],
src/core/short-url-core/dto/short-url-core.dto.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { ShortUrl } from '@prisma/client';
3
+ import { IsArray } from 'class-validator';
4
+ import { CorePaginateDto } from 'src/core/base-query-core/dto/base-query-core.dto';
5
+
6
+ export class ShortUrlCorePaginateDto extends CorePaginateDto {
7
+ @ApiProperty({ required: true })
8
+ @IsArray()
9
+ list?: ShortUrl[];
10
+ }
src/core/short-url-core/short-url-core.module.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { PrismaService } from 'src/shared/modules/prisma/prisma.service';
3
+ import { ShortUrlCoreService } from './short-url-core.service';
4
+
5
+ @Module({
6
+ providers: [PrismaService, ShortUrlCoreService],
7
+ exports: [ShortUrlCoreService, PrismaService],
8
+ })
9
+ export class ShortUrlCoreModule {}
src/core/short-url-core/short-url-core.service.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ShortUrl, Prisma } from '@prisma/client';
3
+ import { PrismaService } from 'src/shared/modules/prisma/prisma.service';
4
+ import { ShortUrlCorePaginateDto } from './dto/short-url-core.dto';
5
+ import { PrismaBaseRepository } from 'src/shared/libs/prisma-base.repository';
6
+ import { ShortUrlMessages } from 'src/shared/keys/short-url.keys';
7
+
8
+ @Injectable()
9
+ export class ShortUrlCoreService extends PrismaBaseRepository<
10
+ ShortUrl,
11
+ ShortUrlCorePaginateDto,
12
+ Prisma.ShortUrlCreateArgs,
13
+ Prisma.ShortUrlUpdateArgs,
14
+ Prisma.ShortUrlUpdateManyArgs,
15
+ Prisma.ShortUrlFindUniqueArgs,
16
+ Prisma.ShortUrlFindFirstArgs,
17
+ Prisma.ShortUrlFindManyArgs,
18
+ Prisma.ShortUrlDeleteArgs,
19
+ Prisma.ShortUrlDeleteManyArgs,
20
+ Prisma.ShortUrlCountArgs,
21
+ Prisma.ShortUrlUpsertArgs
22
+ > {
23
+ constructor(private prismaService: PrismaService) {
24
+ super(prismaService.prisma.shortUrl, {
25
+ NOT_FOUND: ShortUrlMessages.NOT_FOUND,
26
+ DELETED: ShortUrlMessages.DELETED,
27
+ });
28
+ }
29
+ }
src/shared/keys/short-url.keys.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export const ShortUrlMessages = {
2
+ NOT_FOUND: 'Short URL not found',
3
+ DELETED: 'Short URL deleted successfully',
4
+ CREATED: 'Short URL created successfully',
5
+ };
src/shared/modules/short-url/shlink/dto/shlink.dto.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { IsString, IsUrl, IsOptional } from 'class-validator';
3
+
4
+ export class CreateShortUrlDto {
5
+ @ApiProperty({
6
+ description: 'Long URL to be shortened',
7
+ example: 'https://example.com/very/long/url',
8
+ })
9
+ @IsUrl()
10
+ @IsString()
11
+ longUrl: string;
12
+
13
+ @ApiProperty({
14
+ description: 'Optional custom short code',
15
+ required: false,
16
+ example: 'my-custom-code',
17
+ })
18
+ @IsString()
19
+ @IsOptional()
20
+ customSlug?: string;
21
+ }
src/shared/modules/short-url/shlink/shlink.config.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { registerAs } from '@nestjs/config';
2
+
3
+ export default registerAs('shlink', () => ({
4
+ url: process.env.SHLINK_URL,
5
+ apiKey: process.env.SHLINK_API_KEY,
6
+ }));
src/shared/modules/short-url/shlink/shlink.controller.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Controller,
3
+ Get,
4
+ Post,
5
+ Delete,
6
+ Body,
7
+ Param,
8
+ Query,
9
+ HttpCode,
10
+ HttpStatus,
11
+ } from '@nestjs/common';
12
+ import { ApiTags, ApiOperation, ApiQuery, ApiParam } from '@nestjs/swagger';
13
+ import { ShlinkService } from './shlink.service';
14
+ import { CreateShortUrlDto } from './dto/shlink.dto';
15
+ import { URL_SHORTENER_PROVIDER } from '@prisma/client';
16
+ import { LogToDiscord } from 'src/shared/decorators/log-to-discord.decorator';
17
+
18
+ @ApiTags('Short URL: Shlink')
19
+ @Controller('short-url/shlink')
20
+ @LogToDiscord()
21
+ export class ShlinkController {
22
+ constructor(private readonly shlinkService: ShlinkService) {}
23
+
24
+ @Post()
25
+ @ApiOperation({ summary: 'Create a new short URL' })
26
+ async createShortUrl(@Body() createDto: CreateShortUrlDto) {
27
+ return this.shlinkService.createShortUrl(createDto);
28
+ }
29
+
30
+ @Get()
31
+ @ApiOperation({ summary: 'Get all short URLs by provider' })
32
+ @ApiQuery({
33
+ name: 'provider',
34
+ enum: URL_SHORTENER_PROVIDER,
35
+ required: false,
36
+ description: 'Filter by provider (default: SHLINK)',
37
+ })
38
+ async getAllShortUrls(@Query('provider') provider?: URL_SHORTENER_PROVIDER) {
39
+ return this.shlinkService.getAllShortUrls(provider);
40
+ }
41
+
42
+ @Get(':id')
43
+ @ApiOperation({ summary: 'Get a short URL by ID' })
44
+ @ApiParam({ name: 'id', description: 'Short URL ID' })
45
+ async getShortUrlById(@Param('id') id: string) {
46
+ return this.shlinkService.getShortUrlById(id);
47
+ }
48
+
49
+ @Delete(':id')
50
+ @HttpCode(HttpStatus.OK)
51
+ @ApiOperation({ summary: 'Delete a short URL' })
52
+ @ApiParam({ name: 'id', description: 'Short URL ID' })
53
+ async deleteShortUrl(@Param('id') id: string) {
54
+ return this.shlinkService.deleteShortUrl(id);
55
+ }
56
+ }
src/shared/modules/short-url/shlink/shlink.module.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { ShlinkController } from './shlink.controller';
4
+ import { ShlinkService } from './shlink.service';
5
+ import shlinkConfig from './shlink.config';
6
+ import { ShortUrlCoreModule } from 'src/core/short-url-core/short-url-core.module';
7
+
8
+ @Module({
9
+ imports: [ConfigModule.forFeature(shlinkConfig), ShortUrlCoreModule],
10
+ controllers: [ShlinkController],
11
+ providers: [ShlinkService],
12
+ exports: [ShlinkService],
13
+ })
14
+ export class ShlinkModule {}
src/shared/modules/short-url/shlink/shlink.service.ts ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Injectable,
3
+ Logger,
4
+ BadRequestException,
5
+ NotFoundException,
6
+ } from '@nestjs/common';
7
+ import { ConfigService } from '@nestjs/config';
8
+ import { URL_SHORTENER_PROVIDER } from '@prisma/client';
9
+ import { ShortUrlCoreService } from 'src/core/short-url-core/short-url-core.service';
10
+ import { CreateShortUrlDto } from './dto/shlink.dto';
11
+
12
+ @Injectable()
13
+ export class ShlinkService {
14
+ private readonly logger = new Logger(ShlinkService.name);
15
+ private readonly shlinkUrl: string;
16
+ private readonly shlinkApiKey: string;
17
+
18
+ constructor(
19
+ private configService: ConfigService,
20
+ private shortUrlCoreService: ShortUrlCoreService,
21
+ ) {
22
+ this.shlinkUrl = this.configService.get<string>('shlink.url') || '';
23
+ this.shlinkApiKey = this.configService.get<string>('shlink.apiKey') || '';
24
+
25
+ if (!this.shlinkUrl || !this.shlinkApiKey) {
26
+ this.logger.warn('Shlink URL or API Key is not configured');
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Create a short URL using Shlink API
32
+ */
33
+ async createShortUrl(createDto: CreateShortUrlDto) {
34
+ try {
35
+ // Prepare request payload
36
+ const payload: any = {
37
+ longUrl: createDto.longUrl,
38
+ findIfExists: true,
39
+ };
40
+
41
+ if (createDto.customSlug) {
42
+ payload.customSlug = createDto.customSlug;
43
+ }
44
+
45
+ // Call Shlink REST API
46
+ const response = await fetch(`${this.shlinkUrl}/rest/v3/short-urls`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'X-Api-Key': this.shlinkApiKey,
51
+ },
52
+ body: JSON.stringify(payload),
53
+ });
54
+
55
+ if (!response.ok) {
56
+ const errorData = await response.json().catch(() => ({}));
57
+ this.logger.error('Failed to create short URL:', errorData);
58
+ throw new BadRequestException(
59
+ errorData.detail || 'Failed to create short URL',
60
+ );
61
+ }
62
+
63
+ const data = await response.json();
64
+
65
+ // Extract short code from the shortUrl
66
+ const shortCode = data.shortCode || data.shortUrl?.split('/').pop();
67
+
68
+ // Save to database
69
+ const shortUrl = await this.shortUrlCoreService.create({
70
+ data: {
71
+ longUrl: data.longUrl,
72
+ shortUrl: data.shortUrl,
73
+ shortCode: shortCode,
74
+ provider: URL_SHORTENER_PROVIDER.SHLINK,
75
+ },
76
+ });
77
+
78
+ this.logger.log(
79
+ `Created short URL: ${shortUrl.shortUrl} for ${shortUrl.longUrl}`,
80
+ );
81
+
82
+ return shortUrl;
83
+ } catch (error) {
84
+ this.logger.error('Error creating short URL:', error);
85
+ throw new BadRequestException('Failed to create short URL');
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Get all short URLs by provider
91
+ */
92
+ async getAllShortUrls(
93
+ provider: URL_SHORTENER_PROVIDER = URL_SHORTENER_PROVIDER.SHLINK,
94
+ ) {
95
+ try {
96
+ const shortUrls = await this.shortUrlCoreService.findMany({
97
+ where: {
98
+ provider: provider,
99
+ isDeleted: false,
100
+ },
101
+ orderBy: {
102
+ createdAt: 'desc',
103
+ },
104
+ });
105
+
106
+ return shortUrls;
107
+ } catch (error) {
108
+ this.logger.error('Error fetching short URLs:', error);
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Delete a short URL (soft delete from database and delete from Shlink)
115
+ */
116
+ async deleteShortUrl(id: string) {
117
+ try {
118
+ // Find the short URL in database
119
+ const shortUrl = await this.shortUrlCoreService.findUnique({
120
+ where: { id },
121
+ });
122
+
123
+ if (!shortUrl) {
124
+ throw new NotFoundException('Short URL not found');
125
+ }
126
+
127
+ // Delete from Shlink REST API
128
+ if (shortUrl.shortCode) {
129
+ try {
130
+ const response = await fetch(
131
+ `${this.shlinkUrl}/rest/v3/short-urls/${shortUrl.shortCode}`,
132
+ {
133
+ method: 'DELETE',
134
+ headers: {
135
+ 'X-Api-Key': this.shlinkApiKey,
136
+ },
137
+ },
138
+ );
139
+
140
+ if (!response.ok && response.status !== 404) {
141
+ this.logger.warn(
142
+ `Failed to delete from Shlink: ${response.status}`,
143
+ );
144
+ } else {
145
+ this.logger.log(
146
+ `Deleted short URL from Shlink: ${shortUrl.shortCode}`,
147
+ );
148
+ }
149
+ } catch (error) {
150
+ this.logger.warn(
151
+ 'Error deleting from Shlink, continuing with DB deletion:',
152
+ error,
153
+ );
154
+ }
155
+ }
156
+
157
+ // Hard delete from database
158
+ await this.shortUrlCoreService.delete({
159
+ where: { id },
160
+ });
161
+
162
+ this.logger.log(`Deleted short URL: ${id}`);
163
+
164
+ return {
165
+ message: 'Short URL deleted successfully',
166
+ id: shortUrl.id,
167
+ };
168
+ } catch (error) {
169
+ this.logger.error('Error deleting short URL:', error);
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Get a single short URL by ID
176
+ */
177
+ async getShortUrlById(id: string) {
178
+ try {
179
+ const shortUrl = await this.shortUrlCoreService.findUnique({
180
+ where: { id },
181
+ });
182
+
183
+ if (!shortUrl) {
184
+ throw new NotFoundException('Short URL not found');
185
+ }
186
+
187
+ return shortUrl;
188
+ } catch (error) {
189
+ this.logger.error('Error fetching short URL:', error);
190
+ throw error;
191
+ }
192
+ }
193
+ }
src/shared/modules/short-url/short-url.module.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { ShlinkModule } from './shlink/shlink.module';
3
+
4
+ @Module({
5
+ imports: [ShlinkModule],
6
+ exports: [ShlinkModule],
7
+ })
8
+ export class ShortUrlModule {}