├── .gitignore ├── .dockerignore ├── src ├── controllers │ ├── index.ts │ └── users │ │ ├── index.ts │ │ ├── signup.ts │ │ ├── remove.ts │ │ ├── verify.ts │ │ └── find.ts ├── core │ ├── decorators │ │ ├── index.ts │ │ ├── types.ts │ │ ├── endpoint.ts │ │ ├── validationHandler.ts │ │ └── requestHandler.ts │ ├── validations │ │ ├── index.ts │ │ ├── schemas.ts │ │ └── joiOptions.ts │ ├── index.ts │ └── exceptions │ │ ├── index.ts │ │ ├── consts.ts │ │ ├── HttpException.ts │ │ └── MongoErrors.ts ├── middlewares │ ├── index.ts │ └── exceptionHandler.ts ├── mailer │ ├── index.ts │ ├── base.mailer.ts │ └── verification.mailer.ts ├── config │ ├── email.env.ts │ ├── sendgrid.env.ts │ ├── frontend.env.ts │ ├── mongo.env.ts │ ├── index.ts │ └── env.ts ├── services │ └── tokenGenerator.ts ├── index.ts ├── routes │ ├── users.route.ts │ └── index.ts ├── database.ts ├── utils │ └── createVerificationEmail.ts ├── models │ └── user.ts └── server.ts ├── .vscode ├── extensions.json └── settings.json ├── .env.example ├── .editorconfig ├── tsconfig.json ├── test ├── utils.ts ├── basic.test.ts └── users.test.ts ├── README.md ├── .github └── workflows │ └── test.yml ├── LICENSE ├── Dockerfile ├── package.json └── biome.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/.env 4 | .git -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * as users from './users'; 2 | -------------------------------------------------------------------------------- /src/core/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './endpoint'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /src/core/validations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schemas'; 2 | export * from './joiOptions'; 3 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export { default as exceptionHandler } from './exceptionHandler'; 2 | -------------------------------------------------------------------------------- /src/mailer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.mailer'; 2 | export * from './verification.mailer'; 3 | -------------------------------------------------------------------------------- /src/config/email.env.ts: -------------------------------------------------------------------------------- 1 | import env from './env'; 2 | 3 | export const emailEnvVar = env('EMAIL'); 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["streetsidesoftware.code-spell-checker", "biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exceptions'; 2 | export * from './decorators'; 3 | export * from './validations'; 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://localhost/team-up 2 | EMAIL= 3 | SENDGRID_API_KEY= 4 | FRONTEND_BASE_URI=https://team-up.tk -------------------------------------------------------------------------------- /src/config/sendgrid.env.ts: -------------------------------------------------------------------------------- 1 | import env from './env'; 2 | 3 | export const sendGridAPIKeyEnvVar = env('SENDGRID_API_KEY'); 4 | -------------------------------------------------------------------------------- /src/config/frontend.env.ts: -------------------------------------------------------------------------------- 1 | import env from './env'; 2 | 3 | export const frontendBaseURIEnvVar = env('FRONTEND_BASE_URI'); 4 | -------------------------------------------------------------------------------- /src/core/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HttpException } from './HttpException'; 2 | export * from './consts'; 3 | export * from './MongoErrors'; 4 | -------------------------------------------------------------------------------- /src/config/mongo.env.ts: -------------------------------------------------------------------------------- 1 | import env from './env'; 2 | 3 | // Example: MONGODB_URI=mongodb://localhost:27017/db-name 4 | export const mongoURIEnvVar = env('MONGODB_URI'); 5 | -------------------------------------------------------------------------------- /src/core/validations/schemas.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const JoiObjectId = Joi.string() 4 | .regex(/^[0-9a-fA-F]{24}$/, 'objectId') 5 | .message('is invalid ID'); 6 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export { default as env } from './env'; 2 | export * from './mongo.env'; 3 | export * from './sendgrid.env'; 4 | export * from './email.env'; 5 | export * from './frontend.env'; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*.{ts,json,js}] 5 | end_of_line = crlf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /src/core/validations/joiOptions.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const JOI_VALIDATION_OPTIONS: Joi.ValidationOptions = { 4 | convert: true, 5 | abortEarly: false, 6 | errors: { label: false }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/controllers/users/index.ts: -------------------------------------------------------------------------------- 1 | export { default as find } from './find'; 2 | export { default as remove } from './remove'; 3 | export { default as signup } from './signup'; 4 | export { default as verify } from './verify'; 5 | -------------------------------------------------------------------------------- /src/services/tokenGenerator.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | 3 | /** 4 | * returns a random hex string 5 | * @param tokenLength default: 16 6 | */ 7 | export function generateToken(tokenLength = 16): string { 8 | return randomBytes(tokenLength).toString('hex'); 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports.biome": "explicit", 4 | "quickfix.biome": "explicit" 5 | }, 6 | "[typescript]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | }, 9 | "[json]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors'; 2 | import * as database from './database'; 3 | import * as server from './server'; 4 | 5 | database 6 | .connect() 7 | .then(server.run) 8 | .then(() => console.info(colors.green('Up and Running!'))) 9 | .catch((err) => { 10 | console.error(err); 11 | process.exit(1); 12 | }); 13 | -------------------------------------------------------------------------------- /src/routes/users.route.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { users } from '../controllers'; 3 | 4 | const router = express.Router(); 5 | 6 | router.post('/', users.signup); 7 | router.get('/', users.find); 8 | router.delete('/:token', users.remove); 9 | router.put('/verify/:token', users.verify); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | function env(name: string, required?: true): string; 2 | function env(name: string, required: false): string | undefined; 3 | function env(name: string, required = true): string | undefined { 4 | const envVar = process.env[name]; 5 | if (required && envVar === undefined) { 6 | console.error(`Environement variable "${name}" is required but not set.`); 7 | process.exit(1); 8 | } 9 | return envVar; 10 | } 11 | 12 | export default env; 13 | -------------------------------------------------------------------------------- /src/core/exceptions/consts.ts: -------------------------------------------------------------------------------- 1 | import { HttpExceptionStatus } from './HttpException'; 2 | 3 | export const BAD_REQUEST: HttpExceptionStatus = 400; 4 | export const UNAUTHORIZED: HttpExceptionStatus = 401; 5 | export const FORBIDDEN: HttpExceptionStatus = 403; 6 | export const NOT_FOUND: HttpExceptionStatus = 404; 7 | export const CONFLICT: HttpExceptionStatus = 409; 8 | export const UNPROCESSABLE_ENTITY: HttpExceptionStatus = 422; 9 | export const SERVER_ERROR: HttpExceptionStatus = 500; 10 | -------------------------------------------------------------------------------- /src/database.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors'; 2 | import mongoose from 'mongoose'; 3 | import { mongoURIEnvVar } from './config'; 4 | 5 | export async function connect(): Promise { 6 | try { 7 | mongoose.set('strictQuery', false); 8 | await mongoose.connect(mongoURIEnvVar); 9 | 10 | console.info(colors.green('Successfully connected to Mongodb ✅')); 11 | } catch (err) { 12 | console.error( 13 | colors.red(`Failed to connect to ${colors.yellow(mongoURIEnvVar)}`), 14 | ); 15 | throw err; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { endpoint } from '../core/decorators'; 3 | import { HttpException, NOT_FOUND } from '../core/exceptions'; 4 | import users from './users.route'; 5 | 6 | const router = express.Router(); 7 | 8 | router.use('/users', users); 9 | 10 | // healthcheck 11 | router.get( 12 | '/ping', 13 | endpoint(() => 'pong'), 14 | ); 15 | 16 | // 404 17 | router.all('*', () => { 18 | throw new HttpException(NOT_FOUND, { message: 'Are you lost?' }); 19 | }); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "target": "ES2022", 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "outDir": "dist", 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "resolveJsonModule": true, 15 | "strict": true 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server'; 2 | import mongoose from 'mongoose'; 3 | 4 | let mongoServer: MongoMemoryServer; 5 | 6 | export const connectToDatabase = async () => { 7 | mongoServer = await MongoMemoryServer.create(); 8 | mongoose.set('strictQuery', false); 9 | await mongoose.connect(mongoServer.getUri(), { 10 | dbName: 'testing', 11 | }); 12 | }; 13 | 14 | export const disconnectFromDatabase = async () => { 15 | await mongoose.connection.dropDatabase(); 16 | await mongoose.disconnect(); 17 | await mongoServer.stop(); 18 | }; 19 | -------------------------------------------------------------------------------- /src/controllers/users/signup.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { SuccessfulResponse, endpoint } from '../../core/decorators'; 3 | import { VerificationMailer } from '../../mailer'; 4 | import { UserModel, userValidations } from '../../models/user'; 5 | 6 | export default endpoint( 7 | { 8 | body: userValidations, 9 | }, 10 | async (req: Request): Promise => { 11 | const user = await UserModel.create(req.body); 12 | new VerificationMailer(user).sendEmail(); 13 | 14 | return { status: 201, content: 'Registered Successfully' }; 15 | }, 16 | ); 17 | -------------------------------------------------------------------------------- /src/core/exceptions/HttpException.ts: -------------------------------------------------------------------------------- 1 | import { IErrorResponse } from '../decorators'; 2 | 3 | export type HttpExceptionStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500; 4 | 5 | export type ExceptionDetails = { message?: string; errors?: IErrorResponse[] }; 6 | 7 | export default class HttpException extends Error { 8 | status: HttpExceptionStatus; 9 | errors?: Record | IErrorResponse[]; 10 | constructor( 11 | status: HttpExceptionStatus, 12 | { message, errors }: ExceptionDetails = {}, 13 | ) { 14 | super(message); 15 | this.status = status; 16 | this.errors = errors; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Team Up | Backend 2 | 3 | This is a TypeScript Node.js project that provides a backend API for the Team Up app. The project uses Express.js as the web framework and MongoDB as the database. 4 | 5 | 6 | [![Testing](https://github.com/kerolloz/team-up-backend/actions/workflows/test.yml/badge.svg)](https://github.com/kerolloz/team-up-backend/actions/workflows/test.yml) 7 | 8 | The REST API of [_Team Up_](//github.com/kerolloz/team-up) project. 9 | -------------------------------------------------------------------------------- /src/mailer/base.mailer.ts: -------------------------------------------------------------------------------- 1 | import sgMail, { MailDataRequired } from '@sendgrid/mail'; 2 | import { emailEnvVar, sendGridAPIKeyEnvVar } from '../config'; 3 | 4 | sgMail.setApiKey(sendGridAPIKeyEnvVar); 5 | 6 | export abstract class BaseMailer { 7 | protected readonly from = { name: 'TEAM UP', email: emailEnvVar }; 8 | protected abstract get msg(): MailDataRequired; 9 | 10 | public sendEmail() { 11 | if (!this.msg) { 12 | throw new Error('Email not set'); 13 | } 14 | 15 | sgMail 16 | .send(this.msg) 17 | .then(() => console.log(`An email was sent to ${this.msg.to as string}`)) 18 | .catch(console.error); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/middlewares/exceptionHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { HttpException, SERVER_ERROR } from '../core'; 3 | 4 | export default function exceptionHandler( 5 | err: HttpException, 6 | _req: Request, 7 | res: Response, 8 | _next: NextFunction, 9 | ): Response { 10 | const { errors, status = SERVER_ERROR, stack } = err; 11 | let message = err.message === '' ? undefined : err.message; // no empty messages 12 | if (status >= SERVER_ERROR) { 13 | message = 'Internal server error'; // always general message, NEVER expose the error 14 | console.error(stack); 15 | } 16 | return res.status(status).json({ message, errors }); 17 | } 18 | -------------------------------------------------------------------------------- /src/core/decorators/types.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import type Joi from 'joi'; 3 | 4 | export interface ISuccessfulResponse { 5 | status?: 200 | 201 | 202 | 204; 6 | content?: unknown; 7 | } 8 | 9 | export interface IErrorResponse { 10 | label: string; 11 | type: string; 12 | message: string; 13 | } 14 | 15 | export interface IValidationRules { 16 | params?: Joi.SchemaMap; 17 | query?: Joi.SchemaMap; 18 | body?: Joi.SchemaMap; 19 | } 20 | 21 | export type SuccessfulResponse = ISuccessfulResponse | string | void; 22 | export type EndpointHandler = ( 23 | req: Request, 24 | res: Response, 25 | ) => Promise | SuccessfulResponse; 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build-and-test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 20.x 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Lint 24 | run: npm run lint 25 | 26 | - name: Build TypeScript 27 | run: npm run build 28 | 29 | - name: Run tests 30 | run: npm test 31 | env: 32 | MONGODB_URI: anything 33 | EMAIL: anything 34 | SENDGRID_API_KEY: SG.anything 35 | FRONTEND_BASE_URI: https://team-up.tk 36 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import chaiModule from 'chai'; 2 | import chaiHttp from 'chai-http'; 3 | import { app } from '../src/server'; 4 | 5 | const chai = chaiModule.use(chaiHttp); 6 | const { expect } = chai; 7 | 8 | describe('GET /ping', () => { 9 | it('returns 200 with {"message": "pong"}', async () => { 10 | const res = await chai.request(app).get('/ping'); 11 | expect(res.status).to.equal(200); 12 | expect(res.body.message).to.equal('pong'); 13 | }); 14 | }); 15 | 16 | describe('GET /a-path-that-does-not-exist', () => { 17 | it('returns 404 with {"message": "Are you lost?"}', async () => { 18 | const res = await chai.request(app).get('/a-path-that-does-not-exist'); 19 | expect(res.status).to.equal(404); 20 | expect(res.body.message).to.equal('Are you lost?'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/core/exceptions/MongoErrors.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentType } from '@typegoose/typegoose'; 2 | import type { 3 | BulkWriteOperationError, 4 | BulkWriteResult, 5 | MongoError, 6 | } from 'mongodb'; 7 | 8 | export const VALIDATION_ERROR = 'Validation Error'; 9 | export const MONGO_DUPLICATE_KEY_ERROR_CODE = 11000; 10 | export interface IDuplicateKeyError extends MongoError { 11 | code: 11000; 12 | keyValue: Record; 13 | } 14 | 15 | export interface IBulkInsertManyError extends BulkWriteOperationError { 16 | result: { 17 | result: BulkWriteResult; 18 | }; 19 | insertedDocs: DocumentType[]; 20 | } 21 | export interface IMongooseIdValidatorError { 22 | errors: Record< 23 | string, 24 | { 25 | properties: { 26 | message: string; 27 | path: string; 28 | }; 29 | } 30 | >; 31 | } 32 | -------------------------------------------------------------------------------- /src/controllers/users/remove.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import Joi from 'joi'; 3 | import mongoose from 'mongoose'; 4 | import { SuccessfulResponse, endpoint } from '../../core/decorators'; 5 | import { BAD_REQUEST, HttpException } from '../../core/exceptions'; 6 | import { UserModel } from '../../models/user'; 7 | 8 | export default endpoint( 9 | { params: { token: Joi.string().required() } }, 10 | async (req: Request): Promise => { 11 | const verificationToken = req.params.token; 12 | await UserModel.findOneAndDelete({ verificationToken }) 13 | .orFail() 14 | .catch((err) => { 15 | if (err instanceof mongoose.Error.DocumentNotFoundError) { 16 | throw new HttpException(BAD_REQUEST, { 17 | message: 'Invalid verification token', 18 | }); 19 | } 20 | }); 21 | }, 22 | ); 23 | -------------------------------------------------------------------------------- /src/core/decorators/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { requestHandler } from './requestHandler'; 3 | import { EndpointHandler, IValidationRules } from './types'; 4 | import { validationHandler } from './validationHandler'; 5 | 6 | export function endpoint(handler: EndpointHandler): RequestHandler[]; 7 | 8 | export function endpoint( 9 | validationRules: IValidationRules, 10 | handler: EndpointHandler, 11 | ): RequestHandler[]; 12 | 13 | export function endpoint( 14 | rulesOrHandler: IValidationRules | EndpointHandler, 15 | handler?: EndpointHandler, 16 | ): RequestHandler[] { 17 | const handlers = []; 18 | if (handler) { 19 | handlers.push(validationHandler(rulesOrHandler as IValidationRules)); 20 | } 21 | const handlerFunc = handler || rulesOrHandler; 22 | handlers.push(requestHandler(handlerFunc as EndpointHandler)); 23 | 24 | return handlers; 25 | } 26 | -------------------------------------------------------------------------------- /src/controllers/users/verify.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import mongoose from 'mongoose'; 3 | import { BAD_REQUEST, HttpException } from '../../core'; 4 | import { endpoint } from '../../core/decorators'; 5 | import { UserModel } from '../../models/user'; 6 | 7 | export default endpoint( 8 | { params: { token: Joi.string().required() } }, 9 | async (req) => { 10 | const verificationToken = req.params.token; 11 | await UserModel.findOneAndUpdate( 12 | { verificationToken }, 13 | { $set: { isVerified: true } }, 14 | ) 15 | .orFail() 16 | .catch((err) => { 17 | if (err instanceof mongoose.Error.DocumentNotFoundError) { 18 | throw new HttpException(BAD_REQUEST, { 19 | message: 'Invalid verification token', 20 | }); 21 | } 22 | throw err; 23 | }); 24 | return 'Your email has been verified successfully!'; 25 | }, 26 | ); 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kerollos Magdy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/createVerificationEmail.ts: -------------------------------------------------------------------------------- 1 | import { frontendBaseURIEnvVar } from '../config'; 2 | import { User } from '../models/user'; 3 | 4 | /** 5 | * creates html for the verification email 6 | */ 7 | export function createVerificationEmail(this: User): string { 8 | const verificationLink = `${frontendBaseURIEnvVar}/verify?token=${this.verificationToken}`; 9 | const removeLink = `${frontendBaseURIEnvVar}/remove?token=${this.verificationToken}`; 10 | 11 | return ` 12 |

Welcome to TEAM UP!


13 |

We are happy to see you, ${this.name}.

14 |

15 | Please use this link to verify your Email. 16 |

17 | If there is anything wrong with the data you provided, 18 | You can easily remove yourself from our database. 19 |
20 | Note: When you join a team, we recommend that you remove yourself from our database. 21 | So, only students who haven't joined a team yet would appear on our website. 22 |

23 | 24 | Facing any trouble? report an issue here 25 | 26 | `; 27 | } 28 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass, modelOptions, prop } from '@typegoose/typegoose'; 2 | import Joi from 'joi'; 3 | import { generateToken } from '../services/tokenGenerator'; 4 | 5 | @modelOptions({ 6 | schemaOptions: { 7 | toJSON: { 8 | versionKey: false, 9 | }, 10 | }, 11 | }) 12 | export class User { 13 | @prop({ required: true, text: true }) 14 | name!: string; 15 | 16 | @prop({ required: true, unique: true }) 17 | email!: string; 18 | 19 | @prop({ required: true, type: String }) 20 | skills!: string[]; 21 | 22 | @prop({ default: false, select: false }) 23 | isVerified!: boolean; 24 | 25 | @prop({ required: true, select: false, default: () => generateToken() }) 26 | verificationToken!: string; 27 | } 28 | 29 | export const UserModel = getModelForClass(User); 30 | 31 | export const userValidations = { 32 | name: Joi.string() 33 | .regex(/^[a-zA-Z][a-zA-Z\s]*$/) 34 | .min(5) 35 | .max(100) 36 | .required(), 37 | email: Joi.string().email().required(), 38 | skills: Joi.array() 39 | .items( 40 | Joi.string() 41 | .regex(/^[a-z][a-z0-9\-+.]*$/) 42 | .min(2) 43 | .max(20), 44 | ) 45 | .min(2) 46 | .max(20) 47 | .required(), 48 | }; 49 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import colors from 'colors'; 3 | import cors from 'cors'; 4 | import express from 'express'; 5 | import helmet from 'helmet'; 6 | import morgan from 'morgan'; 7 | import { exceptionHandler } from './middlewares'; 8 | import routes from './routes'; 9 | 10 | const { NODE_ENV = 'development', PORT } = process.env; 11 | export const app = express(); 12 | 13 | // [TODO] use winston 14 | const logger = 15 | NODE_ENV === 'development' 16 | ? morgan('dev') 17 | : morgan('combined', { 18 | skip: (_, res) => res.statusCode < 500, 19 | }); 20 | 21 | app.use(logger); 22 | app.use(express.json({ limit: '5mb' })); 23 | app.use(express.urlencoded({ limit: '5mb', extended: true })); 24 | app.use(helmet()); 25 | app.use(cors()); 26 | app.use(routes); 27 | app.use(exceptionHandler); 28 | 29 | export function run(): Promise { 30 | return new Promise((resolve, reject) => { 31 | const port = (PORT || 5000).toString(); 32 | const server = app.listen(port); 33 | 34 | server.once('listening', () => { 35 | console.info( 36 | colors.green(`Server is listening on port ${colors.yellow(port)}`), 37 | ); 38 | resolve(server); 39 | }); 40 | 41 | server.once('error', (err) => { 42 | console.error( 43 | colors.red(`Server failed to listen on port ${colors.yellow(port)}`), 44 | ); 45 | reject(err); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # https://bit.ly/node-docker-best-practice 2 | # This is a multistage Dockerfile. 3 | # In the first stage we install system build dependencies, copy project files and build them 4 | # In the second stage, we start fresh and only copy necessary files. We also purge node_modules devDependencies. 5 | 6 | #### --- Build stage --- #### 7 | FROM node:18-alpine AS build 8 | 9 | # Update npm to latest version 10 | RUN npm i -g npm 11 | 12 | # Only copy node dependency information and install all dependencies first 13 | COPY --chown=node:node package.json package-lock.json ./ 14 | 15 | # Install packages using the lockfiles as source of truth ✅ See bullet point #8.5 about npm ci 16 | # Don't use postinstall scripts to build the app. The source code files are not copied yet. 17 | RUN npm ci 18 | 19 | # Copy source code (and all other relevant files) 20 | COPY --chown=node:node . . 21 | 22 | # Build code (TypeScript) 23 | RUN npm run build 24 | 25 | #### --- Run-time stage --- #### 26 | 27 | # ✅ See bullet point #8.10 about smaller docker base images 28 | FROM node:18-alpine as app 29 | 30 | # Update npm to latest version 31 | RUN npm i -g npm 32 | 33 | # Set non-root user 34 | USER node 35 | 36 | WORKDIR /home/node/app 37 | 38 | # ✅ See bullet point #5.15 about Set NODE_ENV=production 39 | ENV NODE_ENV production 40 | 41 | # Copy dependency information and build output from previous stage 42 | COPY --chown=node:node --from=build package.json package-lock.json ./ 43 | COPY --chown=node:node --from=build node_modules ./node_modules 44 | COPY --chown=node:node --from=build dist ./dist 45 | 46 | # Clean dev dependencies ✅ See bullet point #8.5 47 | RUN npm prune --production && npm cache clean --force 48 | 49 | # ✅ See bullet point #8.2 about avoiding npm start 50 | CMD [ "node", "." ] 51 | -------------------------------------------------------------------------------- /src/controllers/users/find.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import Joi from 'joi'; 3 | import mongoose from 'mongoose'; 4 | import { SuccessfulResponse, endpoint } from '../../core/decorators'; 5 | import { HttpException, UNPROCESSABLE_ENTITY } from '../../core/exceptions'; 6 | import { UserModel } from '../../models/user'; 7 | 8 | async function findBySkills(skills: string[]): Promise { 9 | return await UserModel.find({ 10 | isVerified: true, 11 | skills: { $all: skills }, 12 | }); 13 | } 14 | 15 | async function findByName(name: string): Promise { 16 | return await UserModel.find({ isVerified: true, $text: { $search: name } }); 17 | } 18 | 19 | export default endpoint( 20 | { 21 | query: { 22 | skills: Joi.alternatives( 23 | Joi.array().items(Joi.string().lowercase()), 24 | Joi.string().lowercase(), 25 | ), 26 | name: Joi.string(), 27 | }, 28 | }, 29 | async (req: Request): Promise => { 30 | const { name, skills } = req.query; 31 | 32 | if (!name && !skills) { 33 | return { content: [] }; // Return empty array if no query is passed 34 | } 35 | 36 | if (name && skills) { 37 | throw new HttpException(UNPROCESSABLE_ENTITY, { 38 | message: 'You should use only one search parameter', 39 | errors: [ 40 | { 41 | label: 'query.search', 42 | type: 'query', 43 | message: 'use name or skills', 44 | }, 45 | ], 46 | }); 47 | } 48 | 49 | const users = skills 50 | ? await findBySkills( 51 | (typeof skills === 'string' ? [skills] : skills) as string[], 52 | ) 53 | : await findByName(name as string); 54 | 55 | return { content: users }; 56 | }, 57 | ); 58 | -------------------------------------------------------------------------------- /src/mailer/verification.mailer.ts: -------------------------------------------------------------------------------- 1 | import { MailDataRequired } from '@sendgrid/mail'; 2 | import { frontendBaseURIEnvVar } from '../config'; 3 | import { User } from '../models/user'; 4 | import { BaseMailer } from './base.mailer'; 5 | 6 | export class VerificationMailer extends BaseMailer { 7 | private readonly SUBJECT = 'Team Up - Verify your Email'; 8 | private name: string; 9 | private verificationToken: string; 10 | private to: string; 11 | 12 | constructor({ email: to, verificationToken, name }: User) { 13 | super(); 14 | this.to = to; 15 | this.name = name; 16 | this.verificationToken = verificationToken; 17 | } 18 | 19 | get msg(): MailDataRequired { 20 | return { 21 | to: this.to, 22 | from: this.from, 23 | subject: this.SUBJECT, 24 | html: this.html, 25 | }; 26 | } 27 | 28 | get html(): string { 29 | const { name, verificationToken } = this; 30 | const verificationLink = `${frontendBaseURIEnvVar}/verify?token=${verificationToken}`; 31 | const removeLink = `${frontendBaseURIEnvVar}/remove?token=${verificationToken}`; 32 | 33 | return ` 34 |

Welcome to TEAM UP!


35 |

We are happy to see you, ${name}.

36 |

37 | Please use this link to verify your Email. 38 |

39 | If there is anything wrong with the data you provided, 40 | You can easily remove yourself from our database. 41 |
42 | Note: When you join a team, we recommend that you remove yourself from our database. 43 | So, only students who haven't joined a team yet would appear on our website. 44 |

45 | 46 | Facing any trouble? report an issue here 47 | 48 | `; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "team-up.backend-api", 3 | "version": "3.0.0", 4 | "description": "A simple web application to gather teams for graduation project", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "start": "npm run serve", 8 | "serve": "node -r dotenv/config -r source-map-support/register dist", 9 | "dev": "ts-node-dev -r dotenv/config -r source-map-support/register ./src/index.ts", 10 | "build": "tsc", 11 | "lint": "biome ci .", 12 | "test": "MONGOMS_VERSION=7.0.0 NODE_ENV=test mocha --timeout 50000 -r ts-node/register -r dotenv/config ./test/**/*.test.ts" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/kerolloz/team-up.git" 17 | }, 18 | "author": "Kerollos Magdy", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/kerolloz/team-up/issues" 22 | }, 23 | "homepage": "https://github.com/kerolloz/team-up#readme", 24 | "dependencies": { 25 | "@sendgrid/mail": "^8.1.3", 26 | "@typegoose/typegoose": "^12.5.0", 27 | "colors": "^1.4.0", 28 | "cors": "^2.8.5", 29 | "dotenv": "^16.4.5", 30 | "express": "^4.19.2", 31 | "helmet": "^7.1.0", 32 | "joi": "^17.13.1", 33 | "mongoose": "~8.4.1", 34 | "morgan": "^1.10.0", 35 | "source-map-support": "^0.5.21", 36 | "typescript": "^5.4.5" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "^1.8.1", 40 | "@types/chai": "^4.3.6", 41 | "@types/cors": "^2.8.17", 42 | "@types/express": "^4.17.21", 43 | "@types/mocha": "^10.0.6", 44 | "@types/morgan": "^1.9.9", 45 | "@types/sinon": "^17.0.3", 46 | "mocha": "^10.4.0", 47 | "chai": "^4.3.10", 48 | "chai-http": "^4.4.0", 49 | "sinon": "^18.0.0", 50 | "ts-node": "^10.9.2", 51 | "mongodb-memory-server": "^9.3.0", 52 | "ts-node-dev": "^2.0.0" 53 | }, 54 | "trustedDependencies": ["@biomejs/biome", "mongodb-memory-server"] 55 | } 56 | -------------------------------------------------------------------------------- /src/core/decorators/validationHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, RequestHandler, Response } from 'express'; 2 | import Joi from 'joi'; 3 | import { HttpException, NOT_FOUND, UNPROCESSABLE_ENTITY } from '../exceptions'; 4 | import { VALIDATION_ERROR } from '../exceptions/MongoErrors'; 5 | import { JOI_VALIDATION_OPTIONS } from '../validations/joiOptions'; 6 | import { IErrorResponse, IValidationRules } from './types'; 7 | 8 | export function validationHandler({ 9 | params = {}, 10 | query = {}, 11 | body = undefined, 12 | }: IValidationRules): RequestHandler { 13 | return (req: Request, _: Response, next: NextFunction): void => { 14 | // check for invalid params first, fails => not found 15 | const paramsSchema = Joi.object(params); 16 | const paramsValue = req.params; 17 | const { error: paramsError } = paramsSchema.validate( 18 | paramsValue, 19 | JOI_VALIDATION_OPTIONS, 20 | ); 21 | if (paramsError) { 22 | throw new HttpException(NOT_FOUND, { message: 'Not found' }); 23 | } 24 | 25 | // check for invalid query or body 26 | const schema = Joi.object({ 27 | query: Joi.object(query), 28 | body: Joi.object(body), 29 | }); 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 32 | const { error, value } = schema.validate( 33 | { query: req.query, body: req.body as unknown }, 34 | JOI_VALIDATION_OPTIONS, 35 | ); 36 | 37 | // update req with validated values 38 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 39 | req.query = value.query; 40 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 41 | req.body = value.body; 42 | 43 | if (error) { 44 | const errors = toErrorResponse(error.details); 45 | throw new HttpException(UNPROCESSABLE_ENTITY, { 46 | message: VALIDATION_ERROR, 47 | errors, 48 | }); 49 | } 50 | 51 | next(); 52 | }; 53 | } 54 | 55 | export function toErrorResponse( 56 | errDetails: Joi.ValidationErrorItem[], 57 | ): IErrorResponse[] { 58 | return errDetails.map( 59 | ({ message, path, type }): IErrorResponse => ({ 60 | label: path.join('.'), 61 | type, 62 | message, 63 | }), 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/core/decorators/requestHandler.ts: -------------------------------------------------------------------------------- 1 | import { mongoose } from '@typegoose/typegoose'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { 4 | BAD_REQUEST, 5 | CONFLICT, 6 | HttpException, 7 | NOT_FOUND, 8 | UNPROCESSABLE_ENTITY, 9 | } from '../exceptions'; 10 | import { 11 | IDuplicateKeyError, 12 | MONGO_DUPLICATE_KEY_ERROR_CODE, 13 | VALIDATION_ERROR, 14 | } from '../exceptions/MongoErrors'; 15 | import { EndpointHandler, SuccessfulResponse } from './types'; 16 | 17 | export function requestHandler(handler: EndpointHandler) { 18 | return async ( 19 | req: Request, 20 | res: Response, 21 | next: NextFunction, 22 | ): Promise => { 23 | let response: SuccessfulResponse; 24 | try { 25 | response = await handler(req, res); 26 | } catch (err: unknown) { 27 | const error = err as IDuplicateKeyError; 28 | const { code } = error; 29 | if (code === MONGO_DUPLICATE_KEY_ERROR_CODE) { 30 | const label = Object.keys(error.keyValue).join('.'); 31 | const value = Object.values(error.keyValue).join(','); 32 | const type = 'any.duplicate'; 33 | const message = `${label} (${value}) already exists`; 34 | return next( 35 | new HttpException(CONFLICT, { 36 | message: VALIDATION_ERROR, 37 | errors: [{ label, type, message }], 38 | }), 39 | ); 40 | } 41 | if (err instanceof mongoose.Error.ValidationError) { 42 | return next( 43 | new HttpException(UNPROCESSABLE_ENTITY, { 44 | message: VALIDATION_ERROR, 45 | errors: Object.entries(err.errors).map(([k, v]) => ({ 46 | label: k, 47 | type: (v as mongoose.Error.ValidatorError).kind, 48 | message: v.message, 49 | })), 50 | }), 51 | ); 52 | } 53 | if (err instanceof mongoose.Error.DocumentNotFoundError) { 54 | return next( 55 | new HttpException(NOT_FOUND, { 56 | message: 'Record was not found', 57 | }), 58 | ); 59 | } 60 | if (err instanceof mongoose.Error.CastError) { 61 | return next( 62 | new HttpException(BAD_REQUEST, { 63 | message: 'Invalid Value', 64 | errors: [ 65 | { 66 | label: err.path, 67 | message: `Can't cast ${err.stringValue} to ${err.kind}`, 68 | type: 'Cast Error', 69 | }, 70 | ], 71 | }), 72 | ); 73 | } 74 | 75 | // otherwise, can't recognize error type => continue to error handler (Internal Server Error) 76 | return next(err); 77 | } 78 | 79 | if (res.headersSent) { 80 | return; 81 | } 82 | 83 | if (response === null || response === undefined) { 84 | return res.status(204).send(); 85 | } 86 | 87 | // content 88 | let content = typeof response === 'string' ? response : response.content; 89 | if (typeof content === 'string') { 90 | content = { message: content }; // convert string to obj 91 | } else { 92 | content = content || {}; // always return obj 93 | } 94 | 95 | // status 96 | let status = 200; 97 | if (typeof response != 'string' && response.status) { 98 | ({ status } = response); 99 | } else if (content == undefined) { 100 | status = 204; 101 | } 102 | 103 | return res.status(status).json(content); 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", 3 | "formatter": { 4 | "ignore": ["**/node_modules/", "**/dist/"], 5 | "enabled": true, 6 | "formatWithErrors": false, 7 | "indentStyle": "space", 8 | "indentWidth": 2, 9 | "lineEnding": "crlf", 10 | "lineWidth": 80, 11 | "attributePosition": "auto" 12 | }, 13 | "organizeImports": { "enabled": true }, 14 | "linter": { 15 | "include": ["src/**/*.ts"], 16 | "enabled": true, 17 | "rules": { 18 | "recommended": false, 19 | "complexity": { 20 | "noBannedTypes": "error", 21 | "noExtraBooleanCast": "error", 22 | "noMultipleSpacesInRegularExpressionLiterals": "error", 23 | "noUselessCatch": "error", 24 | "noUselessThisAlias": "error", 25 | "noUselessTypeConstraint": "error", 26 | "noWith": "error", 27 | "useArrowFunction": "off" 28 | }, 29 | "correctness": { 30 | "noConstAssign": "error", 31 | "noConstantCondition": "error", 32 | "noEmptyCharacterClassInRegex": "error", 33 | "noEmptyPattern": "error", 34 | "noGlobalObjectCalls": "error", 35 | "noInvalidConstructorSuper": "error", 36 | "noInvalidNewBuiltin": "error", 37 | "noNonoctalDecimalEscape": "error", 38 | "noPrecisionLoss": "error", 39 | "noSelfAssign": "error", 40 | "noSetterReturn": "error", 41 | "noSwitchDeclarations": "error", 42 | "noUndeclaredVariables": "error", 43 | "noUnreachable": "error", 44 | "noUnreachableSuper": "error", 45 | "noUnsafeFinally": "error", 46 | "noUnsafeOptionalChaining": "error", 47 | "noUnusedLabels": "error", 48 | "noUnusedPrivateClassMembers": "error", 49 | "noUnusedVariables": "error", 50 | "useArrayLiterals": "off", 51 | "useIsNan": "error", 52 | "useValidForDirection": "error", 53 | "useYield": "error" 54 | }, 55 | "style": { 56 | "noNamespace": "error", 57 | "useAsConstAssertion": "error", 58 | "useBlockStatements": "warn" 59 | }, 60 | "suspicious": { 61 | "noAssignInExpressions": "error", 62 | "noAsyncPromiseExecutor": "error", 63 | "noCatchAssign": "error", 64 | "noClassAssign": "error", 65 | "noCompareNegZero": "error", 66 | "noControlCharactersInRegex": "error", 67 | "noDebugger": "error", 68 | "noDuplicateCase": "error", 69 | "noDuplicateClassMembers": "error", 70 | "noDuplicateObjectKeys": "error", 71 | "noDuplicateParameters": "error", 72 | "noEmptyBlockStatements": "error", 73 | "noExplicitAny": "error", 74 | "noExtraNonNullAssertion": "error", 75 | "noFallthroughSwitchClause": "error", 76 | "noFunctionAssign": "error", 77 | "noGlobalAssign": "error", 78 | "noImportAssign": "error", 79 | "noMisleadingCharacterClass": "error", 80 | "noMisleadingInstantiator": "error", 81 | "noPrototypeBuiltins": "error", 82 | "noRedeclare": "error", 83 | "noShadowRestrictedNames": "error", 84 | "noUnsafeDeclarationMerging": "error", 85 | "noUnsafeNegation": "error", 86 | "useAwait": "error", 87 | "useGetterReturn": "error", 88 | "useValidTypeof": "error" 89 | } 90 | }, 91 | "ignore": ["**/node_modules/", "**/dist/"] 92 | }, 93 | "javascript": { 94 | "formatter": { 95 | "quoteProperties": "asNeeded", 96 | "trailingCommas": "all", 97 | "semicolons": "always", 98 | "arrowParentheses": "always", 99 | "bracketSpacing": true, 100 | "bracketSameLine": false, 101 | "quoteStyle": "single", 102 | "attributePosition": "auto" 103 | } 104 | }, 105 | "overrides": [ 106 | { 107 | "include": ["*.ts", "*.tsx"], 108 | "linter": { 109 | "rules": { 110 | "correctness": { 111 | "noConstAssign": "off", 112 | "noGlobalObjectCalls": "off", 113 | "noInvalidConstructorSuper": "off", 114 | "noInvalidNewBuiltin": "off", 115 | "noNewSymbol": "off", 116 | "noSetterReturn": "off", 117 | "noUndeclaredVariables": "off", 118 | "noUnreachable": "off", 119 | "noUnreachableSuper": "off" 120 | }, 121 | "style": { 122 | "noArguments": "error", 123 | "noVar": "error", 124 | "useConst": "error" 125 | }, 126 | "suspicious": { 127 | "noDuplicateClassMembers": "off", 128 | "noDuplicateObjectKeys": "off", 129 | "noDuplicateParameters": "off", 130 | "noFunctionAssign": "off", 131 | "noImportAssign": "off", 132 | "noRedeclare": "off", 133 | "noUnsafeNegation": "off", 134 | "useGetterReturn": "off" 135 | } 136 | } 137 | } 138 | }, 139 | { 140 | "include": ["*.ts", "*.tsx"], 141 | "linter": { 142 | "rules": { 143 | "correctness": { 144 | "noConstAssign": "off", 145 | "noGlobalObjectCalls": "off", 146 | "noInvalidConstructorSuper": "off", 147 | "noInvalidNewBuiltin": "off", 148 | "noNewSymbol": "off", 149 | "noSetterReturn": "off", 150 | "noUndeclaredVariables": "off", 151 | "noUnreachable": "off", 152 | "noUnreachableSuper": "off" 153 | }, 154 | "style": { 155 | "noArguments": "error", 156 | "noVar": "error", 157 | "useConst": "error" 158 | }, 159 | "suspicious": { 160 | "noDuplicateClassMembers": "off", 161 | "noDuplicateObjectKeys": "off", 162 | "noDuplicateParameters": "off", 163 | "noFunctionAssign": "off", 164 | "noImportAssign": "off", 165 | "noRedeclare": "off", 166 | "noUnsafeNegation": "off", 167 | "useGetterReturn": "off" 168 | } 169 | } 170 | } 171 | }, 172 | { 173 | "include": ["*.ts", "*.tsx"], 174 | "linter": { 175 | "rules": { 176 | "correctness": { 177 | "noConstAssign": "off", 178 | "noGlobalObjectCalls": "off", 179 | "noInvalidConstructorSuper": "off", 180 | "noInvalidNewBuiltin": "off", 181 | "noNewSymbol": "off", 182 | "noSetterReturn": "off", 183 | "noUndeclaredVariables": "off", 184 | "noUnreachable": "off", 185 | "noUnreachableSuper": "off" 186 | }, 187 | "style": { 188 | "noArguments": "error", 189 | "noVar": "error", 190 | "useConst": "error" 191 | }, 192 | "suspicious": { 193 | "noDuplicateClassMembers": "off", 194 | "noDuplicateObjectKeys": "off", 195 | "noDuplicateParameters": "off", 196 | "noFunctionAssign": "off", 197 | "noImportAssign": "off", 198 | "noRedeclare": "off", 199 | "noUnsafeNegation": "off", 200 | "useGetterReturn": "off" 201 | } 202 | } 203 | } 204 | } 205 | ] 206 | } 207 | -------------------------------------------------------------------------------- /test/users.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiHttp from 'chai-http'; 3 | import Sinon from 'sinon'; 4 | import { VerificationMailer } from '../src/mailer'; 5 | import { UserModel } from '../src/models/user'; 6 | import { app } from '../src/server'; 7 | import { connectToDatabase, disconnectFromDatabase } from './utils'; 8 | 9 | chai.use(chaiHttp); 10 | const expect = chai.expect; 11 | 12 | before(connectToDatabase); 13 | after(disconnectFromDatabase); 14 | 15 | const userData = { 16 | name: 'some User', 17 | email: 'test@example.com', 18 | skills: ['javascript', 'typescript'], 19 | }; 20 | 21 | const verifiedUserData = { 22 | ...userData, 23 | isVerified: true, 24 | }; 25 | 26 | beforeEach(async () => { 27 | await UserModel.deleteMany({}); 28 | }); 29 | 30 | describe('POST /users', () => { 31 | it('returns 201 and success message when a new user is registered', async () => { 32 | const sendEmail = Sinon.stub( 33 | VerificationMailer.prototype, 34 | 'sendEmail', 35 | ).resolves(); 36 | const response = await chai.request(app).post('/users').send(userData); 37 | 38 | expect(sendEmail.calledOnce).to.be.true; 39 | expect(response.status).to.equal(201); 40 | expect(response.body.message).to.equal('Registered Successfully'); 41 | }); 42 | 43 | it('returns 409 and error message when an existing user tries to register', async () => { 44 | await UserModel.create(userData); 45 | const response = await chai.request(app).post('/users').send(userData); 46 | 47 | expect(response.status).to.equal(409); 48 | expect(response.body.errors[0].message) 49 | .to.include('email') 50 | .include('already exists'); 51 | }); 52 | 53 | it('returns 422 and error message when an invalid email is provided', async () => { 54 | const response = await chai 55 | .request(app) 56 | .post('/users') 57 | .send({ 58 | name: 'New User', 59 | email: 'invalid email', 60 | skills: ['javascript', 'typescript'], 61 | }); 62 | 63 | expect(response.status).to.equal(422); 64 | expect(response.body.message).to.equal('Validation Error'); 65 | expect(response.body.errors).to.be.an('array'); 66 | expect(response.body.errors).to.have.lengthOf(1); 67 | expect(response.body.errors[0]).to.deep.include({ 68 | label: 'body.email', 69 | type: 'string.email', 70 | message: 'must be a valid email', 71 | }); 72 | }); 73 | 74 | it('returns 422 and error message when skills is less than 2', async () => { 75 | const response = await chai 76 | .request(app) 77 | .post('/users') 78 | .send({ 79 | name: 'New User', 80 | email: 'newuser@example.com', 81 | skills: ['javascript'], 82 | }); 83 | 84 | expect(response.status).to.equal(422); 85 | expect(response.body.message).to.equal('Validation Error'); 86 | expect(response.body.errors).to.be.an('array'); 87 | expect(response.body.errors).to.have.lengthOf(1); 88 | expect(response.body.errors[0]).to.deep.include({ 89 | label: 'body.skills', 90 | type: 'array.min', 91 | message: 'must contain at least 2 items', 92 | }); 93 | }); 94 | }); 95 | 96 | describe('GET /users', () => { 97 | it('returns 200 and an empty list of users when no query is used (no verified users)', async () => { 98 | await UserModel.create(userData); 99 | const response = await chai.request(app).get('/users'); 100 | 101 | expect(response.status).to.equal(200); 102 | expect(response.body).to.be.an('array'); 103 | expect(response.body).to.have.lengthOf(0); 104 | }); 105 | 106 | it('returns 200 and an empty list of users when a query is used (no verified users)', async () => { 107 | await UserModel.create(userData); 108 | const response = await chai 109 | .request(app) 110 | .get('/users') 111 | .query({ skills: 'javascript' }); 112 | 113 | expect(response.status).to.equal(200); 114 | expect(response.body).to.be.an('array'); 115 | expect(response.body).to.have.lengthOf(0); 116 | }); 117 | 118 | it('returns 200 and an empty list of users when no query is used (verified users)', async () => { 119 | await UserModel.create(verifiedUserData); 120 | const response = await chai.request(app).get('/users'); 121 | 122 | expect(response.status).to.equal(200); 123 | expect(response.body).to.be.an('array'); 124 | expect(response.body).to.have.lengthOf(0); 125 | }); 126 | 127 | it('returns 200 and a list of users when a query is used (1 verified user) 1 skill query', async () => { 128 | await UserModel.create(verifiedUserData); 129 | const response = await chai 130 | .request(app) 131 | .get('/users') 132 | .query({ skills: verifiedUserData.skills[0] }); 133 | 134 | expect(response.status).to.equal(200); 135 | expect(response.body).to.be.an('array'); 136 | expect(response.body).to.have.lengthOf(1); 137 | expect(response.body[0]).to.deep.include({ 138 | name: verifiedUserData.name, 139 | email: verifiedUserData.email, 140 | skills: verifiedUserData.skills, 141 | }); 142 | }); 143 | 144 | it('returns 200 and a list of users when a query is used (verified users) and the query is case insensitive', async () => { 145 | await UserModel.create(verifiedUserData); 146 | const response = await chai 147 | .request(app) 148 | .get('/users') 149 | .query({ skills: verifiedUserData.skills[0].toUpperCase() }); 150 | 151 | expect(response.status).to.equal(200); 152 | expect(response.body).to.be.an('array'); 153 | expect(response.body).to.have.lengthOf(1); 154 | expect(response.body[0]).to.deep.include({ 155 | name: verifiedUserData.name, 156 | email: verifiedUserData.email, 157 | skills: verifiedUserData.skills, 158 | }); 159 | }); 160 | 161 | it('returns 200 and a list of users when name is used for query', async () => { 162 | const users = [ 163 | verifiedUserData, 164 | { ...verifiedUserData, email: 'different@email.com' }, 165 | ]; 166 | await UserModel.create(users); 167 | const response = await chai 168 | .request(app) 169 | .get('/users') 170 | .query({ name: verifiedUserData.name }); 171 | 172 | expect(response.status).to.equal(200); 173 | expect(response.body).to.be.an('array'); 174 | expect(response.body).to.have.lengthOf(2); 175 | }); 176 | 177 | it('returns 200 and a list of users when 2 skills are used for query', async () => { 178 | const users = [ 179 | verifiedUserData, 180 | { ...verifiedUserData, email: 'different@email.com' }, 181 | ]; 182 | await UserModel.create(users); 183 | const response = await chai 184 | .request(app) 185 | .get('/users?skills=javasCRIPT&skills=TYPEScript'); 186 | 187 | expect(response.status).to.equal(200); 188 | expect(response.body).to.be.an('array'); 189 | expect(response.body).to.have.lengthOf(2); 190 | }); 191 | }); 192 | 193 | describe('PUT /verify/:token', () => { 194 | it('returns 200 and a success message when a valid token is provided', async () => { 195 | const user = await UserModel.create(userData); 196 | const response = await chai 197 | .request(app) 198 | .put(`/users/verify/${user.verificationToken}`); 199 | 200 | expect(response.status).to.equal(200); 201 | expect(response.body.message).to.equal( 202 | 'Your email has been verified successfully!', 203 | ); 204 | }); 205 | 206 | it('returns 200 and a success message when a valid token is provided and the user is already verified -> endpoint is idempotent (i.e., multiple requests with the same token result in the same outcome)', async () => { 207 | const user = await UserModel.create(verifiedUserData); 208 | const response = await chai 209 | .request(app) 210 | .put(`/users/verify/${user.verificationToken}`); 211 | 212 | expect(response.status).to.equal(200); 213 | expect(response.body.message).to.equal( 214 | 'Your email has been verified successfully!', 215 | ); 216 | }); 217 | 218 | it('returns 400 and an error message when an invalid token is provided', async () => { 219 | const response = await chai.request(app).put('/users/verify/invalid-token'); 220 | 221 | expect(response.status).to.equal(400); 222 | expect(response.body.message).to.equal('Invalid verification token'); 223 | }); 224 | }); 225 | 226 | describe('DELETE /users/:token', () => { 227 | it('returns 204 and an empty body when a valid token is provided', async () => { 228 | const user = await UserModel.create(userData); 229 | const response = await chai 230 | .request(app) 231 | .delete(`/users/${user.verificationToken}`); 232 | 233 | expect(response.status).to.equal(204); 234 | expect(response.body).to.have.empty.length; 235 | }); 236 | 237 | it('returns 400 and an error message when an invalid token is provided', async () => { 238 | const response = await chai.request(app).delete('/users/invalid-token'); 239 | 240 | expect(response.status).to.equal(400); 241 | expect(response.body.message).to.equal('Invalid verification token'); 242 | }); 243 | }); 244 | --------------------------------------------------------------------------------