Skip to content

Commit ea02b10

Browse files
committed
feat(coupon): implement coupon management functionality; add controller, service, repository, and DTOs
1 parent 7bbd4b2 commit ea02b10

File tree

19 files changed

+544
-9
lines changed

19 files changed

+544
-9
lines changed

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
AuthController,
2121
BillingController,
2222
ContractController,
23+
CouponController,
2324
getConfigService,
2425
ProjectController,
2526
SubscriptionController,
@@ -97,6 +98,7 @@ useExpressServer(app, {
9798
ContractController,
9899
TenderlyController,
99100
BillingController,
101+
CouponController,
100102
],
101103
middlewares: [AppErrorHandler, AuthMiddleware],
102104
});

src/middleware/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { diConstants } from '@bonadocs/di';
88
import type { BonadocsLogger } from '@bonadocs/logger';
99

1010
import { AuthService } from '../modules/auth/auth.service';
11-
import { ApplicationError } from '../modules/errors/ApplicationError';
11+
import { ApplicationError, applicationErrorCodes } from '../modules/errors/ApplicationError';
1212

1313
@Service()
1414
@Middleware({ type: 'before' })
@@ -34,6 +34,9 @@ export class AuthMiddleware implements ExpressMiddlewareInterface {
3434
throw new ApplicationError({
3535
message: 'Invalid token',
3636
logger: request.logger,
37+
errorCode: applicationErrorCodes.unauthenticated,
38+
userFriendlyMessage: 'Invalid Token',
39+
statusCode: 401,
3740
});
3841
}
3942

src/middleware/errors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export default class AppErrorHandler implements ExpressErrorMiddlewareInterface
3333
return;
3434
}
3535

36+
if ((<Error>error).name === 'PayloadTooLargeError') {
37+
response.status(StatusCodes.REQUEST_TOO_LONG).json({
38+
status: 'failed',
39+
message: 'Request payload too large',
40+
});
41+
return;
42+
}
43+
3644
if (request.logger) {
3745
request.logger?.error('Unexpected error occurred', error);
3846
} else {

src/modules/auth/auth.service.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ describe('AuthService', () => {
139139
},
140140
authData,
141141
),
142-
).rejects.toHaveProperty('errorCode', 'UNAUTHORIZED');
142+
).rejects.toHaveProperty('errorCode', 'UNAUTHENTICATED');
143143
});
144144
});
145145
});

src/modules/auth/auth.service.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export class AuthService {
9191
message: 'Failed to authenticate user',
9292
logger: this.logger,
9393
userFriendlyMessage: 'Please check your credentials and try again',
94+
statusCode: 401,
9495
});
9596
}
9697

@@ -155,7 +156,9 @@ export class AuthService {
155156
throw new ApplicationError({
156157
message: 'User is not authenticated',
157158
logger: this.logger,
158-
errorCode: applicationErrorCodes.unauthorized,
159+
errorCode: applicationErrorCodes.unauthenticated,
160+
statusCode: 401,
161+
userFriendlyMessage: 'You must be logged in to access this service',
159162
});
160163
}
161164
// check if projectId and collectionId are provided exits
@@ -165,6 +168,7 @@ export class AuthService {
165168
message: 'Project not found',
166169
logger: this.logger,
167170
errorCode: applicationErrorCodes.unauthorized,
171+
statusCode: 403,
168172
});
169173
}
170174
const collectionExist = await this.projectRepository.checkIfCollectionExist(
@@ -176,6 +180,7 @@ export class AuthService {
176180
message: 'Collection not found',
177181
logger: this.logger,
178182
errorCode: applicationErrorCodes.unauthorized,
183+
statusCode: 403,
179184
});
180185
}
181186
// check if user has permission to write to the collection
@@ -185,6 +190,7 @@ export class AuthService {
185190
message: 'You do not have permission to write in this collection, contact your admin',
186191
logger: this.logger,
187192
errorCode: applicationErrorCodes.unauthorized,
193+
statusCode: 403,
188194
});
189195
}
190196
const validityPeriod = this.validityFromEnv ? Number(this.validityFromEnv) : 6 * 3600;
@@ -213,6 +219,7 @@ export class AuthService {
213219
throw new ApplicationError({
214220
message: 'You do not have permission to write in this collection, contact your admin',
215221
errorCode: applicationErrorCodes.unauthorized,
222+
statusCode: 403,
216223
logger: this.logger,
217224
});
218225
}
@@ -225,7 +232,8 @@ export class AuthService {
225232
throw new ApplicationError({
226233
message: 'Unsupported auth source',
227234
logger: this.logger,
228-
errorCode: applicationErrorCodes.unauthorized,
235+
errorCode: applicationErrorCodes.unauthenticated,
236+
statusCode: 401,
229237
});
230238
}
231239
return func;
@@ -316,7 +324,8 @@ export class AuthService {
316324
throw new ApplicationError({
317325
message: 'Invalid or expired API key',
318326
logger: this.logger,
319-
errorCode: applicationErrorCodes.unauthorized,
327+
errorCode: applicationErrorCodes.unauthenticated,
328+
statusCode: 401,
320329
});
321330
}
322331

@@ -333,7 +342,8 @@ export class AuthService {
333342
throw new ApplicationError({
334343
message: 'Invalid JWT',
335344
logger: this.logger,
336-
errorCode: applicationErrorCodes.unauthorized,
345+
errorCode: applicationErrorCodes.unauthenticated,
346+
statusCode: 401,
337347
});
338348
}
339349

@@ -342,14 +352,16 @@ export class AuthService {
342352
throw new ApplicationError({
343353
message: 'Invalid JWT - purpose ws server',
344354
logger: this.logger,
345-
errorCode: applicationErrorCodes.unauthorized,
355+
errorCode: applicationErrorCodes.unauthenticated,
356+
statusCode: 401,
346357
});
347358
}
348359
if (!payload.userId) {
349360
throw new ApplicationError({
350361
message: 'Invalid JWT - missing user ID',
351362
logger: this.logger,
352-
errorCode: applicationErrorCodes.unauthorized,
363+
errorCode: applicationErrorCodes.unauthenticated,
364+
statusCode: 401,
353365
});
354366
}
355367

@@ -369,6 +381,7 @@ export class AuthService {
369381
message: 'Invalid auth data',
370382
logger: this.logger,
371383
errorCode: applicationErrorCodes.unauthenticated,
384+
statusCode: 401,
372385
});
373386
}
374387

@@ -379,6 +392,7 @@ export class AuthService {
379392
message: 'Invalid Firebase ID Token',
380393
errorCode: applicationErrorCodes.unauthenticated,
381394
logger: this.logger,
395+
statusCode: 401,
382396
});
383397
}
384398

@@ -407,6 +421,7 @@ export class AuthService {
407421
message: 'Invalid auth data',
408422
logger: this.logger,
409423
errorCode: applicationErrorCodes.unauthenticated,
424+
statusCode: 401,
410425
});
411426
}
412427

@@ -417,6 +432,7 @@ export class AuthService {
417432
message: 'Invalid Firebase ID Token',
418433
errorCode: applicationErrorCodes.unauthenticated,
419434
logger: this.logger,
435+
statusCode: 401,
420436
});
421437
}
422438

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Request } from 'express';
2+
import { Body, Get, JsonController, Param, Patch, Post, Req } from 'routing-controllers';
3+
import { Inject, Service } from 'typedi';
4+
5+
import { JsonResponse } from '../shared';
6+
7+
import { CouponResponse } from './coupon.interface';
8+
import { CouponService } from './coupon.service';
9+
import { CreateCouponDto, UpdateCouponStatusDto } from './dto';
10+
11+
@Service()
12+
@JsonController('/coupons')
13+
export class CouponController {
14+
constructor(@Inject() private readonly couponService: CouponService) {}
15+
16+
@Post('/')
17+
async create(
18+
@Body({ validate: true }) payload: CreateCouponDto,
19+
@Req() request: Request,
20+
): Promise<JsonResponse<string>> {
21+
const authData = request.auth;
22+
const response = await this.couponService.create(payload, authData);
23+
return {
24+
data: response,
25+
status: 'successful',
26+
message: 'Created Coupon successfully',
27+
};
28+
}
29+
30+
@Patch('/:couponId')
31+
async change_status(
32+
@Param('couponId') couponId: number,
33+
@Body({ validate: true }) payload: UpdateCouponStatusDto,
34+
): Promise<JsonResponse<string>> {
35+
const response = await this.couponService.changeStatus({
36+
id: couponId,
37+
status: payload.status,
38+
});
39+
return {
40+
data: response,
41+
status: 'successful',
42+
message: 'Updated Coupon successfully',
43+
};
44+
}
45+
46+
@Get('/get-by-projectId/:projectId')
47+
async list(@Param('projectId') projectId: number): Promise<JsonResponse<CouponResponse[]>> {
48+
const response = await this.couponService.listCouponByProjectId(projectId);
49+
return {
50+
data: response,
51+
status: 'successful',
52+
message: 'Coupon Get Successfully',
53+
};
54+
}
55+
56+
@Get('/:couponId')
57+
async get(@Param('couponId') couponId: number): Promise<JsonResponse<CouponResponse>> {
58+
const response = await this.couponService.getById(couponId);
59+
return {
60+
data: response,
61+
status: 'successful',
62+
message: 'Coupon Get Successfully',
63+
};
64+
}
65+
66+
@Get('/get-by-code/:code')
67+
async getByCode(@Param('code') code: string): Promise<JsonResponse<CouponResponse>> {
68+
const response = await this.couponService.getByCode(code);
69+
return {
70+
data: response,
71+
status: 'successful',
72+
message: 'Coupon Get Successfully',
73+
};
74+
}
75+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { SubscriptionStatus } from '../shared/types/subscriptions';
2+
3+
export interface CreateCouponRequest {
4+
project_id: number;
5+
date_expire: Date;
6+
plan_id: number;
7+
}
8+
9+
export interface CouponResponse {
10+
id: number;
11+
project_id: number;
12+
code: string;
13+
date_created: Date;
14+
date_expire: Date;
15+
plan_id: number;
16+
status: SubscriptionStatus;
17+
subscription_id: number;
18+
}
19+
20+
export interface UpdateCouponStatus {
21+
id: number;
22+
status: SubscriptionStatus;
23+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Inject, Service } from 'typedi';
2+
import { v4 as uuidv4 } from 'uuid';
3+
4+
import { diConstants } from '@bonadocs/di';
5+
import { BonadocsLogger } from '@bonadocs/logger';
6+
7+
import { ApplicationError, applicationErrorCodes } from '../errors';
8+
import { CouponDto, CouponRepository, CreateCouponDto } from '../repositories/coupon';
9+
import { SubscriptionStatus } from '../shared/types/subscriptions';
10+
11+
import { CreateCouponRequest, UpdateCouponStatus } from './coupon.interface';
12+
13+
@Service()
14+
export class CouponService {
15+
constructor(
16+
@Inject(diConstants.logger) private readonly logger: BonadocsLogger,
17+
@Inject() private readonly couponRepository: CouponRepository,
18+
) {}
19+
20+
async create(request: CreateCouponRequest, authData: AuthData): Promise<string> {
21+
const coupon: CreateCouponDto = {
22+
code: this.generateUUIDCouponCode('bonadocs'),
23+
date_expire: request.date_expire,
24+
plan_id: request.plan_id,
25+
project_id: request.project_id,
26+
status: SubscriptionStatus.Active,
27+
creator_id: authData.userId!,
28+
};
29+
// validate if any active coupon exists for the project on the selected plan
30+
const existingCoupons = await this.couponRepository.getCouponsByProjectIdAndPlanId(
31+
request.project_id,
32+
request.plan_id,
33+
);
34+
if (existingCoupons !== undefined) {
35+
this.logger.error('Active coupon already exists for the project on the selected plan');
36+
throw new ApplicationError({
37+
logger: this.logger,
38+
message: 'Active coupon already exists for the project on the selected plan',
39+
errorCode: applicationErrorCodes.invalidRequest,
40+
});
41+
}
42+
const couponCode = await this.couponRepository.createCoupon(coupon);
43+
if (couponCode === undefined) {
44+
this.logger.error('Error creating coupon');
45+
throw new ApplicationError({
46+
logger: this.logger,
47+
message: 'Unable to create coupon',
48+
errorCode: applicationErrorCodes.invalidRequest,
49+
});
50+
}
51+
return couponCode;
52+
}
53+
54+
async changeStatus(request: UpdateCouponStatus): Promise<string> {
55+
const code = await this.couponRepository.changeCouponStatus(request.id, request.status);
56+
if (code === undefined) {
57+
this.logger.error('Error updating coupon status');
58+
throw new ApplicationError({
59+
logger: this.logger,
60+
message: 'Unable to update coupon status',
61+
errorCode: applicationErrorCodes.invalidRequest,
62+
});
63+
}
64+
return code;
65+
}
66+
67+
async getById(couponId: number): Promise<CouponDto> {
68+
const coupon = await this.couponRepository.getCouponById(couponId);
69+
if (coupon === undefined) {
70+
this.logger.error('Error getting coupon by id');
71+
throw new ApplicationError({
72+
logger: this.logger,
73+
message: 'Unable to get coupon by id',
74+
errorCode: applicationErrorCodes.invalidRequest,
75+
});
76+
}
77+
return coupon;
78+
}
79+
80+
async getByCode(couponCode: string): Promise<CouponDto> {
81+
const coupon = await this.couponRepository.getCouponByCode(couponCode);
82+
if (coupon === undefined) {
83+
this.logger.error('Error getting coupon by code');
84+
throw new ApplicationError({
85+
logger: this.logger,
86+
message: 'Unable to get coupon by code',
87+
errorCode: applicationErrorCodes.invalidRequest,
88+
});
89+
}
90+
return coupon;
91+
}
92+
93+
async listCouponByProjectId(projectId: number): Promise<CouponDto[]> {
94+
return this.couponRepository.listCouponsByProjectId(projectId);
95+
}
96+
97+
private generateUUIDCouponCode(prefix = ''): string {
98+
const code = uuidv4().split('-')[0].toUpperCase();
99+
return prefix ? `${prefix}-${code}` : code;
100+
}
101+
}

0 commit comments

Comments
 (0)