Skip to content

Commit 5ecb636

Browse files
authored
[Questions] Add filter params for get, better exceptions (#10)
This PR improves error handling, validation, and filtering in the `questions-services` module. The changes include updating exception handling in the `RpcExceptionFilter`, enhancing validation in the `QuestionsController`, and refining database error handling in the `QuestionsService`. ### Error Handling Improvements: * [`project/apps/api-gateway/src/filters/rpc-exception.filter.ts`](diffhunk://#diff-d296104e4b77e6a77f90203da00e7b5c6e57dfcf641bf954420f8c420e07479fL12-R14): Added a default status code of 500 if `error.statusCode` is undefined. * [`project/packages/pipes/src/zod-validation-pipe.pipe.ts`](diffhunk://#diff-5d1894d6f1391424e0b3b8f193c6472aad155978c55467ed8e0aa250ae37c760L1-R1): Improved error formatting in `ZodValidationPipe` and added structured error responses for validation failures. [[1]](diffhunk://#diff-5d1894d6f1391424e0b3b8f193c6472aad155978c55467ed8e0aa250ae37c760L1-R1) [[2]](diffhunk://#diff-5d1894d6f1391424e0b3b8f193c6472aad155978c55467ed8e0aa250ae37c760R10-R34) ### Validation Enhancements: * [`project/apps/api-gateway/src/questions/questions.controller.ts`](diffhunk://#diff-6d78e3581db5b3d5e0c2347d6bb808a1a71518a54e86de60ce58e2b7bd1e5eb6R14-R28): Moved validation from `questions-service` module to `api-gateway` module. Added `HttpException` and `HttpStatus` for better error responses. [[1]](diffhunk://#diff-6d78e3581db5b3d5e0c2347d6bb808a1a71518a54e86de60ce58e2b7bd1e5eb6R14-R28) [[2]](diffhunk://#diff-6d78e3581db5b3d5e0c2347d6bb808a1a71518a54e86de60ce58e2b7bd1e5eb6L28-R41) [[3]](diffhunk://#diff-6d78e3581db5b3d5e0c2347d6bb808a1a71518a54e86de60ce58e2b7bd1e5eb6R50) [[4]](diffhunk://#diff-6d78e3581db5b3d5e0c2347d6bb808a1a71518a54e86de60ce58e2b7bd1e5eb6L51-R68) * [`project/packages/dtos/src/questions.ts`](diffhunk://#diff-7e9b2f555ea4cdd6633cac37c90e669ed96f8101a81fbb843f7b84647ce95e1dL3-R17): Replaced enum schemas with string schemas for `category` and `complexity`. Added `getQuestionsQuerySchema` for query validation. [[1]](diffhunk://#diff-7e9b2f555ea4cdd6633cac37c90e669ed96f8101a81fbb843f7b84647ce95e1dL3-R17) [[2]](diffhunk://#diff-7e9b2f555ea4cdd6633cac37c90e669ed96f8101a81fbb843f7b84647ce95e1dR33-R34) ### Database Error Handling: * [`project/apps/questions-service/src/questions.service.ts`](diffhunk://#diff-3a7f7758daf8d580911308d1a2a9847a586cad97e1192d50d2ed98f6701b9cb8R3-R14): Refined error handling by introducing `RpcException` for database operations. Updated `findAll` method to support filtering by `title`, `category`, and `complexity`. [[1]](diffhunk://#diff-3a7f7758daf8d580911308d1a2a9847a586cad97e1192d50d2ed98f6701b9cb8R3-R14) [[2]](diffhunk://#diff-3a7f7758daf8d580911308d1a2a9847a586cad97e1192d50d2ed98f6701b9cb8L28-R67) [[3]](diffhunk://#diff-3a7f7758daf8d580911308d1a2a9847a586cad97e1192d50d2ed98f6701b9cb8L60-R80) [[4]](diffhunk://#diff-3a7f7758daf8d580911308d1a2a9847a586cad97e1192d50d2ed98f6701b9cb8L75-R111) [[5]](diffhunk://#diff-3a7f7758daf8d580911308d1a2a9847a586cad97e1192d50d2ed98f6701b9cb8L112-R127)
1 parent 0d5cc1b commit 5ecb636

File tree

6 files changed

+139
-55
lines changed

6 files changed

+139
-55
lines changed

project/apps/api-gateway/src/filters/rpc-exception.filter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export class RpcExceptionFilter implements ExceptionFilter {
99
const ctx = host.switchToHttp();
1010
const response = ctx.getResponse<Response>();
1111

12-
response.status(error.statusCode).json(error);
12+
const statusCode = error?.statusCode || 500;
13+
14+
response.status(statusCode).json(error);
1315
}
1416
}

project/apps/api-gateway/src/questions/questions.controller.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,20 @@ import {
1111
Put,
1212
Delete,
1313
Query,
14+
UsePipes,
15+
BadRequestException,
1416
} from '@nestjs/common';
1517
import { ClientProxy } from '@nestjs/microservices';
1618
import { AuthGuard } from 'src/auth/auth.guard';
17-
import { CreateQuestionDto, UpdateQuestionDto } from '@repo/dtos/questions';
19+
import {
20+
CreateQuestionDto,
21+
createQuestionSchema,
22+
GetQuestionsQueryDto,
23+
getQuestionsQuerySchema,
24+
UpdateQuestionDto,
25+
updateQuestionSchema,
26+
} from '@repo/dtos/questions';
27+
import { ZodValidationPipe } from '@repo/pipes/zod-validation-pipe.pipe';
1828

1929
@Controller('questions')
2030
@UseGuards(AuthGuard) // Can comment out if we dw auth for now
@@ -25,11 +35,9 @@ export class QuestionsController {
2535
) {}
2636

2737
@Get()
28-
async getQuestions(@Query('includeDeleted') includeDeleted: boolean = false) {
29-
return this.questionsServiceClient.send(
30-
{ cmd: 'get_questions' },
31-
includeDeleted,
32-
);
38+
@UsePipes(new ZodValidationPipe(getQuestionsQuerySchema))
39+
async getQuestions(@Query() filters: GetQuestionsQueryDto) {
40+
return this.questionsServiceClient.send({ cmd: 'get_questions' }, filters);
3341
}
3442

3543
@Get(':id')
@@ -38,6 +46,7 @@ export class QuestionsController {
3846
}
3947

4048
@Post()
49+
@UsePipes(new ZodValidationPipe(createQuestionSchema))
4150
async createQuestion(@Body() createQuestionDto: CreateQuestionDto) {
4251
return this.questionsServiceClient.send(
4352
{ cmd: 'create_question' },
@@ -48,10 +57,11 @@ export class QuestionsController {
4857
@Put(':id')
4958
async updateQuestion(
5059
@Param('id') id: string,
51-
@Body() updateQuestionDto: UpdateQuestionDto,
60+
@Body(new ZodValidationPipe(updateQuestionSchema)) // validation on the body only
61+
updateQuestionDto: UpdateQuestionDto,
5262
) {
5363
if (id != updateQuestionDto.id) {
54-
throw new Error('ID in URL does not match ID in request body');
64+
throw new BadRequestException('ID in URL does not match ID in body');
5565
}
5666
return this.questionsServiceClient.send(
5767
{ cmd: 'update_question' },

project/apps/questions-service/src/questions.controller.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
1-
import { Controller, UsePipes } from '@nestjs/common';
1+
import { Controller } from '@nestjs/common';
22
import { MessagePattern, Payload } from '@nestjs/microservices';
33
import { QuestionsService } from './questions.service';
4-
import { ZodValidationPipe } from '@repo/pipes/zod-validation-pipe.pipe';
54
import {
65
CreateQuestionDto,
7-
createQuestionSchema,
6+
GetQuestionsQueryDto,
87
UpdateQuestionDto,
9-
updateQuestionSchema,
108
} from '@repo/dtos/questions';
119
@Controller()
1210
export class QuestionsController {
1311
constructor(private readonly questionsService: QuestionsService) {}
1412

1513
@MessagePattern({ cmd: 'get_questions' })
16-
async getQuestions(includeDeleted: boolean) {
17-
return await this.questionsService.findAll(includeDeleted);
14+
async getQuestions(@Payload() filters: GetQuestionsQueryDto) {
15+
return await this.questionsService.findAll(filters);
1816
}
1917

2018
@MessagePattern({ cmd: 'get_question' })
@@ -23,15 +21,13 @@ export class QuestionsController {
2321
}
2422

2523
@MessagePattern({ cmd: 'create_question' })
26-
@UsePipes(new ZodValidationPipe(createQuestionSchema))
2724
async createQuestion(@Payload() createQuestionDto: CreateQuestionDto) {
2825
return await this.questionsService.create(createQuestionDto);
2926
}
3027

3128
@MessagePattern({ cmd: 'update_question' })
32-
@UsePipes(new ZodValidationPipe(updateQuestionSchema))
33-
async updateQuestion(@Payload() createQuestionDto: UpdateQuestionDto) {
34-
return await this.questionsService.update(createQuestionDto);
29+
async updateQuestion(@Payload() updateQuestionDto: UpdateQuestionDto) {
30+
return await this.questionsService.update(updateQuestionDto);
3531
}
3632

3733
@MessagePattern({ cmd: 'delete_question' })

project/apps/questions-service/src/questions.service.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
3+
import { RpcException } from '@nestjs/microservices';
34
import {
45
CreateQuestionDto,
6+
GetQuestionsQueryDto,
57
QuestionDto,
68
UpdateQuestionDto,
79
} from '@repo/dtos/questions';
@@ -25,16 +27,36 @@ export class QuestionsService {
2527
this.supabase = createClient(supabaseUrl, supabaseKey);
2628
}
2729

28-
private handleError(operation: string, error: unknown): never {
29-
this.logger.error(`Error during ${operation}:`, error);
30-
throw error;
30+
/**
31+
* Handles errors by logging the error message and throwing an RpcException.
32+
*
33+
* @private
34+
* @param {string} operation - The name of the operation where the error occurred.
35+
* @param {any} error - The error object that was caught. This can be any type of error, including a NestJS HttpException.
36+
* @throws {RpcException} - Throws an RpcException wrapping the original error.
37+
*/
38+
private handleError(operation: string, error: any): never {
39+
this.logger.error(`Error at ${operation}: ${error.message}`);
40+
41+
throw new RpcException(error);
3142
}
3243

33-
async findAll(includeDeleted: boolean = false): Promise<QuestionDto[]> {
34-
const query = this.supabase.from(this.QUESTIONS_TABLE).select();
44+
async findAll(filters: GetQuestionsQueryDto): Promise<QuestionDto[]> {
45+
const { title, category, complexity, includeDeleted } = filters;
3546

47+
let query = this.supabase.from(this.QUESTIONS_TABLE).select();
48+
49+
if (title) {
50+
query = query.ilike('q_title', `%${title}%`);
51+
}
52+
if (category) {
53+
query = query.contains('q_category', [category]);
54+
}
55+
if (complexity) {
56+
query = query.eq('q_complexity', complexity);
57+
}
3658
if (!includeDeleted) {
37-
query.is('deleted_at', null);
59+
query = query.is('deleted_at', null);
3860
}
3961

4062
const { data, error } = await query;
@@ -44,7 +66,7 @@ export class QuestionsService {
4466
}
4567

4668
this.logger.log(
47-
`fetched ${data.length} questions, includeDeleted: ${includeDeleted}`,
69+
`fetched ${data.length} questions with filters: ${JSON.stringify(filters)}`,
4870
);
4971
return data;
5072
}
@@ -65,6 +87,21 @@ export class QuestionsService {
6587
}
6688

6789
async create(question: CreateQuestionDto): Promise<QuestionDto> {
90+
const { data: existingQuestion } = await this.supabase
91+
.from(this.QUESTIONS_TABLE)
92+
.select()
93+
.eq('q_title', question.q_title)
94+
.single<QuestionDto>();
95+
96+
if (existingQuestion) {
97+
// this.handleError(
98+
// 'create question',
99+
// new BadRequestException(
100+
// `Question with title ${question.q_title} already exists`,
101+
// ),
102+
// );
103+
}
104+
68105
const { data, error } = await this.supabase
69106
.from(this.QUESTIONS_TABLE)
70107
.insert(question)
@@ -80,14 +117,41 @@ export class QuestionsService {
80117
}
81118

82119
async update(question: UpdateQuestionDto): Promise<QuestionDto> {
83-
const updatedQuestion = {
84-
...question,
85-
updated_at: new Date(),
86-
};
120+
// check if the question is soft deleted
121+
const { data: deletedQuestion } = await this.supabase
122+
.from(this.QUESTIONS_TABLE)
123+
.select()
124+
.eq('id', question.id)
125+
.neq('deleted_at', null)
126+
.single<QuestionDto>();
127+
128+
if (deletedQuestion) {
129+
this.handleError(
130+
'update question',
131+
new BadRequestException('Cannot update a deleted question'),
132+
);
133+
}
134+
135+
// check if a question with the same title already exists
136+
const { data: existingQuestion } = await this.supabase
137+
.from(this.QUESTIONS_TABLE)
138+
.select()
139+
.eq('q_title', question.q_title)
140+
.neq('id', question.id)
141+
.single<QuestionDto>();
142+
143+
if (existingQuestion) {
144+
this.handleError(
145+
'update question',
146+
new BadRequestException(
147+
`Question with title ${question.q_title} already exists`,
148+
),
149+
);
150+
}
87151

88152
const { data, error } = await this.supabase
89153
.from(this.QUESTIONS_TABLE)
90-
.update(updatedQuestion)
154+
.update(question)
91155
.eq('id', question.id)
92156
.select()
93157
.single();
Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,26 @@
11
import { z } from "zod";
22

3-
export enum Complexity {
4-
Easy = "Easy",
5-
Medium = "Medium",
6-
Hard = "Hard",
7-
}
8-
9-
export enum Category {
10-
Strings = "Strings",
11-
Algorithms = "Algorithms",
12-
DataStructures = "Data Structures",
13-
BitManipulation = "Bit Manipulation",
14-
Recursion = "Recursion",
15-
Databases = "Databases",
16-
BrainTeaser = "Brain Teaser",
17-
Arrays = "Arrays",
18-
}
19-
20-
const ComplexitySchema = z.nativeEnum(Complexity);
21-
const CategorySchema = z.nativeEnum(Category);
3+
export const categorySchema = z.string().min(1);
4+
export const complexitySchema = z.string().min(1);
5+
6+
export const getQuestionsQuerySchema = z.object({
7+
title: z.string().optional(),
8+
category: categorySchema.optional(),
9+
complexity: complexitySchema.optional(),
10+
includeDeleted: z.coerce.boolean().optional(),
11+
});
2212

2313
const commonQuestionFields = z.object({
2414
q_title: z.string().min(1),
2515
q_desc: z.string().min(1),
26-
q_category: z.array(CategorySchema),
27-
q_complexity: ComplexitySchema,
16+
q_category: z
17+
.array(categorySchema)
18+
.min(1)
19+
// enforce uniqueness of categories
20+
.refine((categories) => new Set(categories).size === categories.length, {
21+
message: "Categories must be unique",
22+
}),
23+
q_complexity: complexitySchema,
2824
});
2925

3026
export const questionSchema = commonQuestionFields.extend({
@@ -40,6 +36,8 @@ export const updateQuestionSchema = commonQuestionFields.extend({
4036
id: z.string().uuid(),
4137
});
4238

39+
export type GetQuestionsQueryDto = z.infer<typeof getQuestionsQuerySchema>;
40+
4341
export type QuestionDto = z.infer<typeof questionSchema>;
4442
export type CreateQuestionDto = z.infer<typeof createQuestionSchema>;
4543
export type UpdateQuestionDto = z.infer<typeof updateQuestionSchema>;

project/packages/pipes/src/zod-validation-pipe.pipe.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
PipeTransform,
33
ArgumentMetadata,
44
BadRequestException,
5+
InternalServerErrorException,
56
} from "@nestjs/common";
67
import { ZodError, ZodSchema } from "zod";
78
import { RpcException } from "@nestjs/microservices";
@@ -15,11 +16,24 @@ export class ZodValidationPipe implements PipeTransform {
1516
return parsedValue;
1617
} catch (error) {
1718
if (error instanceof ZodError) {
19+
// Format Zod errors into a readable format
20+
const formattedErrors = error.errors
21+
.map((err) => {
22+
const path = err.path.join(".");
23+
const message = err.message;
24+
return `${path}: ${message}`;
25+
})
26+
.join(", ");
27+
28+
// Throw HTTP exception wrapped in an RpcException
1829
throw new RpcException(
19-
new BadRequestException(`Validation failed: ${error.errors}`),
30+
new BadRequestException(`Validation failed: ${formattedErrors}`)
2031
);
2132
}
22-
throw new RpcException(new BadRequestException(`Validation failed`));
33+
// Fallback for unknown errors
34+
throw new RpcException(
35+
new InternalServerErrorException("Validation failed, unknown error")
36+
);
2337
}
2438
}
2539
}

0 commit comments

Comments
 (0)