diff --git a/server/eslint.config.js b/server/eslint.config.js index 52e292ef..b2820e27 100644 --- a/server/eslint.config.js +++ b/server/eslint.config.js @@ -22,7 +22,14 @@ export default tseslint.config( rules: { '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-warning-comments': [ + 'warn', + { + terms: ['todo', 'fixme'], + location: 'start', + }, + ], }, } ); diff --git a/server/src/controllers/Trainee/EmploymentHistoryController.ts b/server/src/controllers/Trainee/EmploymentHistoryController.ts index 599e5d00..3919553e 100644 --- a/server/src/controllers/Trainee/EmploymentHistoryController.ts +++ b/server/src/controllers/Trainee/EmploymentHistoryController.ts @@ -1,109 +1,93 @@ -import { Request, Response, NextFunction } from 'express'; -import { ResponseError, EmploymentHistory, validateEmploymentHistory } from '../../models'; +import { Request, Response, RequestHandler } from 'express'; +import { EmploymentHistorySchema, Trainee, EmploymentHistory } from '../../models'; import { TraineesRepository } from '../../repositories'; +import { BadRequestError, NotFoundError } from '../../utils/httpErrors'; +import { TraineeHelper } from './TraineeHelper'; export interface EmploymentHistoryControllerType { - getEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise; - addEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise; - updateEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise; - deleteEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise; + getEmploymentHistory: RequestHandler; + addEmploymentHistory: RequestHandler; + updateEmploymentHistory: RequestHandler; + deleteEmploymentHistory: RequestHandler; } export class EmploymentHistoryController implements EmploymentHistoryControllerType { - constructor(private readonly traineesRepository: TraineesRepository) {} - - async getEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise { - try { - const traineeID = req.params.id; - const employmentHistory = await this.traineesRepository.getEmploymentHistory(traineeID); - res.json(employmentHistory); - } catch (error: any) { - next(error); - } + constructor( + private readonly traineesRepository: TraineesRepository, + private readonly traineeHelper: TraineeHelper + ) {} + + async getEmploymentHistory(req: Request, res: Response): Promise { + // input + const traineeID = req.params.id; + + // check if trainee exists + await this.traineeHelper.getTraineeOrThrow(traineeID); + + // get + const employmentHistory = await this.traineesRepository.getEmploymentHistory(traineeID); + res.json(employmentHistory); } - async addEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise { + async addEmploymentHistory(req: Request, res: Response): Promise { + // input const traineeID = req.params.id; - const employmentHistoryData: EmploymentHistory = req.body; - try { - validateEmploymentHistory(employmentHistoryData); - } catch (error: any) { - res.status(400).send(new ResponseError(error.message)); - return; - } - - try { - const newEmploymentHistory = await this.traineesRepository.addEmploymentHistory(traineeID, employmentHistoryData); - res.status(201).json(newEmploymentHistory); - } catch (error: any) { - next(error); - } + + // validate + const parsed = EmploymentHistorySchema.safeParse(req.body); + if (!parsed.success) throw new BadRequestError(parsed.error, 'stringify'); + const historyToAdd = parsed.data; + + // check if trainee exists + await this.traineeHelper.getTraineeOrThrow(traineeID); + + // add + const addedEmploymentHistory = await this.traineesRepository.addEmploymentHistory(traineeID, historyToAdd); + res.status(201).json(addedEmploymentHistory); } - async updateEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise { - const trainee = await this.traineesRepository.getTrainee(req.params.id); - if (!trainee) { - res.status(404).send(new ResponseError('Trainee not found')); - return; - } + async updateEmploymentHistory(req: Request, res: Response): Promise { + // input + const traineeID = req.params.id; + const employmentHistoryID = req.params.employmentHistoryID; - const employmentHistoryData = trainee.employmentInfo.employmentHistory.find( - (history) => history.id === req.params.employmentHistoryID - ); - if (!employmentHistoryData) { - res.status(404).send(new ResponseError('Employment history was not found')); - return; - } - - const historyToUpdate: EmploymentHistory = { - id: employmentHistoryData.id, - type: req.body.type, - companyName: req.body.companyName, - role: req.body.role, - startDate: new Date(req.body.startDate), - endDate: req.body.endDate ? new Date(req.body.endDate) : undefined, - feeCollected: req.body.feeCollected, - feeAmount: req.body.feeAmount, - comments: req.body.comments, - }; - - try { - validateEmploymentHistory(historyToUpdate); - } catch (error: any) { - res.status(400).send(new ResponseError(error.message)); - return; - } - - try { - const updatedEmploymentHistory = await this.traineesRepository.updateEmploymentHistory( - trainee.id, - historyToUpdate - ); - - res.json(updatedEmploymentHistory); - } catch (error: any) { - next(error); - } + // validate + const parsed = EmploymentHistorySchema.safeParse(req.body); + if (!parsed.success) throw new BadRequestError(parsed.error, 'stringify'); + const historyToUpdate = parsed.data; + + // check if trainee and employment history exists + const trainee = await this.traineeHelper.getTraineeOrThrow(traineeID); + this.getEmploymentHistoryOrThrow(trainee, employmentHistoryID); + + // update + const updatedEmploymentHistory = await this.traineesRepository.updateEmploymentHistory(traineeID, historyToUpdate); + res.json(updatedEmploymentHistory); } - async deleteEmploymentHistory(req: Request, res: Response, next: NextFunction): Promise { - try { - const trainee = await this.traineesRepository.getTrainee(req.params.id); - if (!trainee) { - res.status(404).send(new ResponseError('Trainee not found')); - return; - } - - const employmentHistoryID = req.params.employmentHistoryID; - if (!trainee.employmentInfo.employmentHistory.find((history) => history.id === employmentHistoryID)) { - res.status(404).send(new ResponseError('Employment history not found')); - return; - } - - await this.traineesRepository.deleteEmploymentHistory(trainee.id, employmentHistoryID); - res.status(204).send(); - } catch (error: any) { - next(error); - } + async deleteEmploymentHistory(req: Request, res: Response): Promise { + // input + const traineeID = req.params.id; + const employmentHistoryID = req.params.employmentHistoryID; + + // check if trainee and employment history exists + const trainee = await this.traineeHelper.getTraineeOrThrow(traineeID); + this.getEmploymentHistoryOrThrow(trainee, employmentHistoryID); + + // delete + await this.traineesRepository.deleteEmploymentHistory(traineeID, employmentHistoryID); + res.status(204).send(); + } + + /** + * Helper method to get employment history or throw NotFoundError + */ + private getEmploymentHistoryOrThrow(trainee: Trainee, employmentHistoryID: string): EmploymentHistory { + const employmentHistory = trainee.employmentInfo.employmentHistory.find( + (history) => history.id === employmentHistoryID + ); + + if (!employmentHistory) throw new NotFoundError('Employment history not found'); + return employmentHistory; } } diff --git a/server/src/controllers/Trainee/InteractionController.ts b/server/src/controllers/Trainee/InteractionController.ts index c9243b2b..8d3f828e 100644 --- a/server/src/controllers/Trainee/InteractionController.ts +++ b/server/src/controllers/Trainee/InteractionController.ts @@ -1,129 +1,99 @@ -import { Request, Response, NextFunction } from 'express'; +import { Request, Response, RequestHandler } from 'express'; +import { Trainee, InteractionInputSchema, InteractionWithReporter } from '../../models'; import { TraineesRepository, UserRepository } from '../../repositories'; -import { AuthenticatedUser, InteractionWithReporterID, ResponseError } from '../../models'; import { NotificationService } from '../../services'; -import { validateInteraction } from '../../models/Interaction'; +import { TraineeHelper } from './TraineeHelper'; +import { BadRequestError, NotFoundError } from '../../utils/httpErrors'; export interface InteractionControllerType { - getInteractions(req: Request, res: Response, next: NextFunction): Promise; - addInteraction(req: Request, res: Response, next: NextFunction): Promise; - updateInteraction(req: Request, res: Response, next: NextFunction): Promise; - deleteInteraction(req: Request, res: Response, next: NextFunction): Promise; + getInteractions: RequestHandler; + addInteraction: RequestHandler; + updateInteraction: RequestHandler; + deleteInteraction: RequestHandler; } export class InteractionController implements InteractionControllerType { constructor( private readonly traineesRepository: TraineesRepository, private readonly userRepository: UserRepository, - private notificationService: NotificationService + private notificationService: NotificationService, + private readonly traineeHelper: TraineeHelper ) {} - async getInteractions(req: Request, res: Response, next: NextFunction): Promise { - const trainee = await this.traineesRepository.getTrainee(req.params.id); - if (!trainee) { - res.status(404).send(new ResponseError('Trainee not found')); - return; - } - - try { - const interactions = await this.traineesRepository.getInteractions(trainee.id); - res.status(200).json(interactions); - } catch (error: any) { - next(error); - } + async getInteractions(req: Request, res: Response): Promise { + // input + const traineeID = req.params.id; + + // check if trainee exists + await this.traineeHelper.getTraineeOrThrow(traineeID); + + // get + const interactions = await this.traineesRepository.getInteractions(traineeID); + res.status(200).json(interactions); } - async addInteraction(req: Request, res: Response, next: NextFunction): Promise { - const trainee = await this.traineesRepository.getTrainee(req.params.id); - if (!trainee) { - res.status(404).send(new ResponseError('Trainee not found')); - return; - } - - const { date, type, title, details } = req.body; - const user = res.locals.user as AuthenticatedUser; - const reporterID = req.body.reporterID || user.id; - const newInteraction = { date, type, title, details, reporterID } as InteractionWithReporterID; - - // Validate new interaction model before creation - try { - validateInteraction(newInteraction); - const reporter = await this.userRepository.findUserByID(reporterID); - if (!reporter) { - throw new Error(`Invalid reporter ID ${reporterID}. User not found.`); - } - } catch (error: any) { - res.status(400).send(new ResponseError(error.message)); - return; - } - - try { - const interaction = await this.traineesRepository.addInteraction(req.params.id, newInteraction); - res.status(201).json(interaction); - this.notificationService.interactionCreated(trainee, interaction); - } catch (error: any) { - next(error); - } + async addInteraction(req: Request, res: Response): Promise { + // input + const reporterID = res.locals.user.id; + const traineeID = req.params.id; + + // validate + const parsed = InteractionInputSchema.safeParse({ reporterID, ...req.body }); + if (!parsed.success) throw new BadRequestError(parsed.error, 'stringify'); + const interactionToAdd = parsed.data; + + // get trainee + const trainee = await this.traineeHelper.getTraineeOrThrow(traineeID); + + // check if reporter exists + const reporter = await this.userRepository.findUserByID(reporterID); + if (!reporter) throw new NotFoundError(`Invalid reporter ID ${reporterID}. User not found.`); + + // add and notify + const addedInteraction = await this.traineesRepository.addInteraction(traineeID, interactionToAdd); + res.status(201).json(addedInteraction); + await this.notificationService.interactionCreated(trainee, addedInteraction); } - async updateInteraction(req: Request, res: Response, next: NextFunction): Promise { - const trainee = await this.traineesRepository.getTrainee(req.params.id); - if (!trainee) { - res.status(404).send(new ResponseError('Trainee not found')); - return; - } - - const interaction = trainee.interactions.find((interaction) => interaction.id === req.params.interactionID); - if (!interaction) { - res.status(404).send(new ResponseError('Interaction not found')); - return; - } - - const user = res.locals.user as AuthenticatedUser; - const interactionToUpdate: InteractionWithReporterID = { - id: req.params.interactionID, - date: req.body.date, - type: req.body.type, - title: req.body.title, - details: req.body.details, - reporterID: req.body.reporterID || user.id, - }; - - // Validate new interaction model after applying the changes - try { - validateInteraction(interactionToUpdate); - } catch (error: any) { - res.status(400).send(new ResponseError(error.message)); - return; - } - - try { - const updatedInteraction = await this.traineesRepository.updateInteraction(req.params.id, interactionToUpdate); - res.status(200).json(updatedInteraction); - } catch (error: any) { - console.error(error); - next(error); - return; - } + async updateInteraction(req: Request, res: Response): Promise { + // input + const reporterID = res.locals.user.id; + const traineeID = req.params.id; + const interactionID = req.params.interactionID; + + // validate + const parsed = InteractionInputSchema.safeParse({ reporterID, ...req.body, id: interactionID }); + if (!parsed.success) throw new BadRequestError(parsed.error, 'stringify'); + const interactionToUpdate = parsed.data; + + // check if trainee and interaction exist + const trainee = await this.traineeHelper.getTraineeOrThrow(traineeID); + this.getInteractionOrThrow(trainee, interactionID); + + // update + const updatedInteraction = await this.traineesRepository.updateInteraction(traineeID, interactionToUpdate); + res.status(200).json(updatedInteraction); + } + + async deleteInteraction(req: Request, res: Response): Promise { + // input + const traineeID = req.params.id; + const interactionID = req.params.interactionID; + + // check if trainee and interaction exist + const trainee = await this.traineeHelper.getTraineeOrThrow(traineeID); + this.getInteractionOrThrow(trainee, interactionID); + + // delete + await this.traineesRepository.deleteInteraction(traineeID, interactionID); + res.status(204).end(); } - async deleteInteraction(req: Request, res: Response, next: NextFunction): Promise { - const trainee = await this.traineesRepository.getTrainee(req.params.id); - if (!trainee) { - res.status(404).send(new ResponseError('Trainee not found')); - return; - } - - if (!trainee.interactions.find((interaction) => interaction.id === req.params.interactionID)) { - res.status(404).send(new ResponseError('Interaction not found')); - return; - } - - try { - await this.traineesRepository.deleteInteraction(req.params.id, req.params.interactionID); - res.status(204).end(); - } catch (error: any) { - next(error); - return; - } + /** + * Helper method to get interaction or throw NotFoundError + */ + private getInteractionOrThrow(trainee: Trainee, interactionID: string): InteractionWithReporter { + const interaction = trainee.interactions.find((interaction) => interaction.id === interactionID); + if (!interaction) throw new NotFoundError('Interaction not found'); + return interaction; } } diff --git a/server/src/controllers/Trainee/TraineeHelper.ts b/server/src/controllers/Trainee/TraineeHelper.ts new file mode 100644 index 00000000..1e890149 --- /dev/null +++ b/server/src/controllers/Trainee/TraineeHelper.ts @@ -0,0 +1,22 @@ +import { Trainee } from '../../models'; +import { TraineesRepository } from '../../repositories'; +import { NotFoundError } from '../../utils/httpErrors'; + +export interface TraineeHelper { + getTraineeOrThrow(traineeID: string): Promise; +} + +/** + * This class provides helper for methods that are used repeatedly in the trainee controllers. + */ +export class DefaultTraineeHelper implements TraineeHelper { + constructor(private readonly traineesRepository: TraineesRepository) { + this.getTraineeOrThrow = this.getTraineeOrThrow.bind(this); + } + + async getTraineeOrThrow(traineeID: string): Promise { + const trainee = await this.traineesRepository.getTrainee(traineeID); + if (!trainee) throw new NotFoundError('Trainee not found'); + return trainee; + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 868fa78f..fd03eb77 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -7,6 +7,7 @@ export * from './Trainee/ProfilePictureController'; export * from './Trainee/EmploymentHistoryController'; export * from './Trainee/StrikeController'; export * from './Trainee/LetterController'; +export * from './Trainee/TraineeHelper'; export * from './GeographyController'; export * from './DashboardController'; export * from './CohortsController'; diff --git a/server/src/index.ts b/server/src/index.ts index 475012fe..582fcdec 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -34,6 +34,7 @@ import { StrikeController, DefaultUserController, LetterController, + DefaultTraineeHelper, } from './controllers'; import { MongooseTraineesRepository, MongooseUserRepository, MongooseGeographyRepository } from './repositories'; import { @@ -46,9 +47,9 @@ import { LetterGenerator, PDFService, } from './services'; -import { ResponseError } from './models'; -import { AuthMiddleware } from './middlewares'; +import { AuthMiddleware, errorHandlerMiddleware } from './middlewares'; import { CohortsRouter } from './routes/CohortsRouter'; +import { NotFoundError } from './utils/httpErrors'; class Main { private readonly app: express.Application; @@ -118,6 +119,8 @@ class Main { const traineesRepository = new MongooseTraineesRepository(this.db); const geographyRepository = new MongooseGeographyRepository(this.db); + const traineeHelper = new DefaultTraineeHelper(traineesRepository); + // setup controllers const authenticationController = new AuthenticationController( userRepository, @@ -132,9 +135,14 @@ class Main { uploadService, imageService ); - const interactionController = new InteractionController(traineesRepository, userRepository, notificationService); + const interactionController = new InteractionController( + traineesRepository, + userRepository, + notificationService, + traineeHelper + ); const testController = new TestController(traineesRepository, notificationService); - const employmentHistoryController = new EmploymentHistoryController(traineesRepository); + const employmentHistoryController = new EmploymentHistoryController(traineesRepository, traineeHelper); const strikeController = new StrikeController(traineesRepository, userRepository, notificationService); const searchController = new SearchController(traineesRepository); const geographyController = new GeographyController(geographyRepository); @@ -174,8 +182,8 @@ class Main { this.app.use('/api/admin/users', userRouter.build()); // Not found handler for API - this.app.use('/api', (req: Request, res: Response) => { - res.status(404).json(new ResponseError('Not found')); + this.app.use('/api', () => { + throw new NotFoundError('API endpoint not found'); }); // Serve client static files in production @@ -187,14 +195,7 @@ class Main { setupSentry(this.app); // Global error handler - this.app.use((error: Error, req: Request, res: Response, next: NextFunction) => { - if (this.isProduction) { - res.status(500).json(new ResponseError('Something broke!')); - } else { - console.log(error); - res.status(500).json(error); - } - }); + this.app.use(errorHandlerMiddleware); } async connectToDatabase() { diff --git a/server/src/middlewares/errorHandlerMiddleware.ts b/server/src/middlewares/errorHandlerMiddleware.ts new file mode 100644 index 00000000..c571caaa --- /dev/null +++ b/server/src/middlewares/errorHandlerMiddleware.ts @@ -0,0 +1,38 @@ +import { Request, Response, ErrorRequestHandler } from 'express'; +import { BadRequestError, HttpError, ZodFlattenedError } from '../utils/httpErrors'; +import sentry from '@sentry/node'; + +// ? this is done in a functional style for the following reasons: +// * - no external dependencies are needed and has only one dependent: the express app +// * - to satisfy the linter in a class format, unnecessary code/ignore-comments would be needed + +export const errorHandlerMiddleware: ErrorRequestHandler = (error: Error, req: Request, res: Response) => { + let statusCode: number; + let response: ZodFlattenedError | string | Error; + + // decide status code and response based on the error type + // - ZodError that will be provided as an object + if (error instanceof BadRequestError && error.zodError) { + statusCode = error.statusCode; + response = error.zodError; + } + // - HttpError, including stringified ZodError + else if (error instanceof HttpError) { + statusCode = error.statusCode; + response = error.message; + } + // - Error + else { + const isProduction = process.env.NODE_ENV === 'production'; + + statusCode = 500; + response = isProduction ? 'Something broke!' : error; + + // extra measures for unexpected/unhandled errors + if (isProduction) sentry.captureException(error); + else console.error(error); + } + + // send the response + res.status(statusCode).json({ error: response }); +}; diff --git a/server/src/middlewares/index.ts b/server/src/middlewares/index.ts index f504f2fe..428cb635 100644 --- a/server/src/middlewares/index.ts +++ b/server/src/middlewares/index.ts @@ -1 +1,2 @@ export * from './AuthMiddleware'; +export * from './errorHandlerMiddleware'; diff --git a/server/src/models/EmploymentHistory.ts b/server/src/models/EmploymentHistory.ts index 0babdf42..d1a598c5 100644 --- a/server/src/models/EmploymentHistory.ts +++ b/server/src/models/EmploymentHistory.ts @@ -1,37 +1,35 @@ +import z from 'zod'; + export enum EmploymentType { Internship = 'internship', Job = 'job', } -export interface EmploymentHistory { - readonly id: string; - type: EmploymentType; - companyName: string; - role: string; - startDate: Date; - endDate?: Date; - feeCollected: boolean; - feeAmount?: number; - comments?: string; -} +// export interface EmploymentHistory { +// readonly id: string; +// type: EmploymentType; +// companyName: string; +// role: string; +// startDate: Date; +// endDate?: Date; +// feeCollected: boolean; +// feeAmount?: number; +// comments?: string; +// } + +const EmploymentTypeSchema = z.enum(EmploymentType); + +export const EmploymentHistorySchema = z.object({ + id: z.string(), + type: EmploymentTypeSchema, + companyName: z.string(), + role: z.string(), + startDate: z.date(), + endDate: z.date().optional(), + feeCollected: z.boolean(), + feeAmount: z.number().min(0).optional(), + comments: z.string().optional(), +}); -export const validateEmploymentHistory = (history: EmploymentHistory): void => { - if (!history.role) { - throw new Error('Role is required'); - } - if (!history.companyName) { - throw new Error('Company name is required'); - } - if (typeof history.feeCollected !== 'boolean') { - throw new Error('feeCollected is required'); - } - if (!history.startDate) { - throw new Error('Start date is required'); - } - if (!history.type) { - throw new Error('type is required'); - } - if (!Object.values(EmploymentType).includes(history.type)) { - throw new Error('Unknown employment type value'); - } -}; +// TODO - Just an example, remove and uncomment the interface above +export type EmploymentHistory = ZodToEntity; diff --git a/server/src/models/Interaction.ts b/server/src/models/Interaction.ts index 8a5186ab..205e28ec 100644 --- a/server/src/models/Interaction.ts +++ b/server/src/models/Interaction.ts @@ -1,3 +1,4 @@ +import z from 'zod'; import { DisplayUser } from './User'; export enum InteractionType { @@ -14,7 +15,7 @@ export enum InteractionType { } export interface Interaction { - readonly id: string; + readonly id?: string; date: Date; type: InteractionType; reporterID: string; @@ -26,19 +27,20 @@ export interface Interaction { export type InteractionWithReporter = Interaction & { reporter: DisplayUser }; // For database storage +// ! reporterID is already part of the interface Interaction? export type InteractionWithReporterID = Interaction & { reporterID: string }; -export const validateInteraction = (interaction: InteractionWithReporterID): void => { - if (!interaction.date) { - throw new Error('Interaction date is required'); - } - if (!interaction.reporterID) { - throw new Error('Interaction reporter ID is required'); - } - if (!interaction.details) { - throw new Error('Interaction details are required'); - } - if (!Object.values(InteractionType).includes(interaction.type)) { - throw new Error(`Unknown interaction type [${Object.values(InteractionType)}]`); - } -}; +export const InteractionTypeSchema = z.enum(InteractionType); + +export const InteractionSchema = z.object({ + id: z.string(), + date: z.date(), + type: InteractionTypeSchema, + reporterID: z.string(), + title: z.string(), + details: z.string(), +}); + +export const InteractionInputSchema = InteractionSchema.partial({ + id: true, +}); diff --git a/server/src/models/ResponseError.ts b/server/src/models/ResponseError.ts deleted file mode 100644 index 0f7092fd..00000000 --- a/server/src/models/ResponseError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class ResponseError { - readonly error: string; - constructor(error: string) { - this.error = error; - } -} diff --git a/server/src/types.ts b/server/src/types.ts new file mode 100644 index 00000000..7a264d43 --- /dev/null +++ b/server/src/types.ts @@ -0,0 +1,46 @@ +import { AuthenticatedUser } from './models'; +import z from 'zod'; + +declare global { + // Overwrite the default Express request and response types + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Locals { + user: AuthenticatedUser; + } + } + + /** + * Flatten the type and make it more readable, One level only + * @template T - The type to prettify/flatten + * @example + * interface X {a: string}; + * interface Y {b: number}; + * type Z = X & Y; // X & Y + * type Prettified = Prettify; // { a: string; b: number; } + */ + type Prettify = { + [K in keyof T]: T[K]; + } & {}; + + /** + * Create a record with one readonly property + * @template K - The key name for the property + * @template T - The type of the property value + * @example + * type UserId = ReadOnly<'id', string>; // { readonly id: string } + */ + type ReadOnly = Readonly>; + // common instances + type ReadonlyId = ReadOnly<'id', string>; + + /** + * Create a type from a Zod schema with a readonly id property + * @template Schema - A Zod schema type + * @example + * const userSchema = z.object({ name: z.string(), id: z.string() }); + * type User = ZodToEntity; + * // Result: { readonly id: string; name: string; } + */ + type ZodToEntity = Prettify, 'id'>>; +} diff --git a/server/src/utils/httpErrors.ts b/server/src/utils/httpErrors.ts new file mode 100644 index 00000000..8f1271b1 --- /dev/null +++ b/server/src/utils/httpErrors.ts @@ -0,0 +1,89 @@ +import z, { ZodError } from 'zod'; + +export abstract class HttpError extends Error { + abstract readonly statusCode: number; +} + +export class UnauthorizedError extends HttpError { + readonly statusCode = 401; + + constructor(message = 'Unauthorized') { + super(message); + } +} + +export class ForbiddenError extends HttpError { + readonly statusCode = 403; + + constructor(message = 'Forbidden') { + super(message); + } +} + +export class NotFoundError extends HttpError { + readonly statusCode = 404; + + constructor(message = 'Not Found') { + super(message); + } +} + +export class ConflictError extends HttpError { + readonly statusCode = 409; + + constructor(message = 'Conflict') { + super(message); + } +} + +export class InternalServerError extends HttpError { + readonly statusCode = 500; + + constructor(message = 'Internal Server Error') { + super(message); + } +} + +export type ZodFlattenedError = ReturnType; + +export class BadRequestError extends HttpError { + readonly statusCode = 400; + zodError?: ZodFlattenedError; + + /** + * This class constructor is overloaded to handle two cases: + * @param message - You can provide a string message or leave it empty to use the default message. + * @param error - If you provide a ZodError and you want to use it as string, set stringify to 'stringify'. + */ + constructor(message?: string); + constructor(error: ZodError, stringify: 'stringify' | false); + + // TODO: remove the note below + // ? This can be simplified by deciding whether we want the error field to be always a string or an object in req.body validation errors. + // ? Object is preferred as it's easier to handle or read. + // ? The previous ErrorResponse was always sending a string, so not sure if this has created a format dependency somewhere in the system. + + constructor(reason?: string | ZodError, stringify: 'stringify' | false = false) { + let message: string; + + // assign message based on the type of reason + // reason is ZodError + if (reason instanceof ZodError) { + message = z.prettifyError(reason); + } + // reason is string + else if (typeof reason === 'string') { + message = reason; + } + // reason is not provided + else { + message = 'Bad Request. Make sure your request data is correct.'; + } + super(message); + + // assign zodError if stringify is false + if (!stringify && reason instanceof ZodError) { + this.zodError = z.flattenError(reason); + } + } +}