├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── refactor.yml │ ├── test.yml │ ├── docs.yml │ ├── feature_request.yml │ ├── security_issue.yml │ └── bug_report.yml ├── workflows │ ├── tests.yml │ └── deploy-docs.yml └── PULL_REQUEST_TEMPLATE.md ├── .commitlintrc.json ├── .dockerignore ├── .husky ├── pre-commit └── commit-msg ├── src ├── types │ ├── enums.ts │ ├── types.ts │ └── interfaces.ts ├── utils │ ├── otp.util.ts │ ├── logger.util.ts │ ├── email.util.ts │ └── jwt.util.ts ├── models │ ├── marketingEmail.model.ts │ ├── session.model.ts │ ├── ticket.model.ts │ └── user.model.ts ├── middleware │ ├── rateLimiter.middleware.ts │ ├── requireSameUser.middleware.ts │ ├── requireAdminRole.middleware.ts │ ├── getCurrentConnectedService.middleware.ts │ ├── deserializeUser.middleware.ts │ └── validateRequest.middleware.ts ├── services │ ├── session.service.ts │ ├── marketingEmail.service.ts │ ├── ticket.service.ts │ ├── auth.service.ts │ └── user.service.ts ├── schemas │ ├── marketingEmail.schema.ts │ ├── ticket.schema.ts │ ├── mail.schema.ts │ ├── user.schema.ts │ └── auth.schema.ts ├── routes │ ├── user.routes.ts │ ├── ticket.routes.ts │ ├── mail.routes.ts │ ├── admin.routes.ts │ └── auth.routes.ts ├── app.ts ├── controllers │ ├── ticket.controller.ts │ ├── marketingEmail.controller.ts │ ├── user.controller.ts │ ├── mail.controller.ts │ └── auth.controller.ts └── tests │ └── endpoints.test.js ├── renovate.json ├── typedoc.json ├── Dockerfile ├── .prettierrc ├── .vscode └── settings.json ├── docker-compose.yml ├── LICENSE ├── bin └── index.ts ├── CONTRIBUTING.md ├── package.json ├── .eslintrc ├── .env.example ├── .gitignore ├── CODE_OF_CONDUCT.md ├── .all-contributorsrc ├── jest.config.ts ├── tsconfig.json └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ./node_modules 2 | Dockerfile 3 | .dockerignore 4 | docker-compose.yml 5 | .build -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /src/types/enums.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GUIDE: 3 | * Every enum should have prefix `E` eg: ETicketStatus 4 | */ 5 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GUIDE: 3 | * Every type should have prefix `T` eg: TRootState 4 | */ 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /src/types/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GUIDE: 3 | * Every interface should have prefix `I` eg: IAPIResponse 4 | */ 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/otp.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function will create a random 4 digit OTP 3 | */ 4 | export function generateRandomOTP(): number { 5 | return Math.floor(Math.random() * (9999 - 1000 + 1)) + 1000; 6 | } 7 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "src/*/**/*", 4 | "src/controllers/**/*", 5 | "src/models/**/*", 6 | "src/routes/**/*", 7 | "src/utils/**/*", 8 | "src/app.ts" 9 | ], 10 | "out": "docs" 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | RUN npm install -g typescript 6 | 7 | COPY package.json . 8 | 9 | COPY yarn.lock . 10 | 11 | RUN yarn install 12 | 13 | COPY . . 14 | 15 | CMD [ "yarn", "dev" ] -------------------------------------------------------------------------------- /src/utils/logger.util.ts: -------------------------------------------------------------------------------- 1 | import pino from "pino"; 2 | 3 | const logger = pino({ 4 | transport: { 5 | target: "pino-pretty", 6 | options: { 7 | translateTime: "SYS:dd-mm-yyyy HH:MM:ss", 8 | ignore: "pid,hostname", 9 | }, 10 | }, 11 | }); 12 | 13 | export default logger; 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 100, 4 | "singleQuote": false, 5 | "trailingComma": "all", 6 | "jsxSingleQuote": false, 7 | "bracketSpacing": true, 8 | "tabWidth": 4, 9 | "useTabs": true, 10 | "endOfLine": "lf", 11 | "extends": ["plugin:react/recommended", "prettier"] 12 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["dompurify", "markverified"], 3 | "docwriter.style": "Auto-detect", 4 | "editor.tabSize": 4, 5 | "editor.comments.insertSpace": true, 6 | "editor.autoIndent": "full", 7 | "prettier.tabWidth": 4, 8 | "prettier.arrowParens": "always", 9 | "prettier.bracketSpacing": true, 10 | "editor.insertSpaces": false, 11 | "prettier.useTabs": true 12 | } 13 | -------------------------------------------------------------------------------- /src/models/marketingEmail.model.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass, prop, Ref } from "@typegoose/typegoose"; 2 | import { User } from "./user.model"; 3 | 4 | export class MarketingEmail { 5 | @prop({ required: true }) 6 | public subject: string; 7 | 8 | @prop({ required: true, ref: () => User }) 9 | public users: Ref[]; 10 | } 11 | 12 | const MarketingEmailModel = getModelForClass(MarketingEmail, { 13 | schemaOptions: { timestamps: true }, 14 | }); 15 | 16 | export default MarketingEmailModel; 17 | -------------------------------------------------------------------------------- /src/models/session.model.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass, prop, Ref, index } from "@typegoose/typegoose"; 2 | import { User } from "./user.model"; 3 | 4 | @index({ expireAt: 1 }) 5 | export class Session { 6 | @prop({ required: true, ref: () => User }) 7 | user: Ref; 8 | 9 | @prop({ required: true }) 10 | valid: boolean; 11 | 12 | @prop({ default: Date.now(), expires: "15d" }) 13 | expireAt: Date; 14 | } 15 | 16 | const SessionModel = getModelForClass(Session, { 17 | schemaOptions: { timestamps: true }, 18 | }); 19 | 20 | export default SessionModel; 21 | -------------------------------------------------------------------------------- /src/models/ticket.model.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass, prop } from "@typegoose/typegoose"; 2 | 3 | export class Ticket { 4 | @prop({ required: true }) 5 | name: string; 6 | 7 | @prop({ required: true }) 8 | subject: string; 9 | 10 | @prop({ required: true }) 11 | email: string; 12 | 13 | @prop({ required: true }) 14 | message: string; 15 | 16 | @prop({ default: false }) 17 | status: "new" | "in-progress" | "solved"; 18 | } 19 | 20 | const TicketModel = getModelForClass(Ticket, { 21 | schemaOptions: { timestamps: true }, 22 | }); 23 | 24 | export default TicketModel; 25 | -------------------------------------------------------------------------------- /src/middleware/rateLimiter.middleware.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from "express-rate-limit"; 2 | 3 | /** 4 | * This middleware will limit the amount of request 5 | * from particular IP address 6 | * 7 | * @constant 8 | * @author developer-diganta 9 | */ 10 | const rateLimiter = rateLimit({ 11 | windowMs: 15 * 60 * 1000, // 15 minutes 12 | max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes) 13 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 14 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 15 | }); 16 | 17 | export default rateLimiter; 18 | -------------------------------------------------------------------------------- /src/services/session.service.ts: -------------------------------------------------------------------------------- 1 | import SessionModel from "../models/session.model"; 2 | 3 | /** 4 | * Create a new session for given user 5 | * @param userId id of the user for which session will be created 6 | * 7 | * @author aayushchugh 8 | */ 9 | export async function createSessionService(userId: string) { 10 | return await SessionModel.create({ user: userId, valid: true }); 11 | } 12 | 13 | /** 14 | * Finds session form the database with matching id 15 | * @param sessionId id of the session 16 | * 17 | * @author aayushchugh 18 | */ 19 | export function findSessionByIdService(sessionId: string) { 20 | return SessionModel.findById(sessionId); 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | # Mongodb services 4 | mongo_db: 5 | container_name: Database 6 | image: mongo:latest 7 | restart: always 8 | ports: 9 | - 2717:27017 10 | volumes: 11 | - mongo_db:/data/db 12 | # Server 13 | api: 14 | container_name: Server 15 | build: . 16 | volumes: 17 | - ./src:/usr/src/app/src 18 | - ./bin:/usr/src/app/bin 19 | ports: 20 | - 3001:3001 21 | env_file: 22 | - .env 23 | depends_on: 24 | - mongo_db 25 | volumes: 26 | mongo_db: {} 27 | -------------------------------------------------------------------------------- /src/schemas/marketingEmail.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * This schema is used to validate `POST /marketing` request 5 | * 6 | * @author tharun634, aayushchugh 7 | */ 8 | export const createMarketingEmailSchema = z.object({ 9 | body: z.object({ 10 | subject: z.string({ required_error: "subject is required" }), 11 | html: z.string({ required_error: "html is required" }), 12 | specificUsers: z.array(z.string()).optional(), 13 | allUsers: z.boolean().optional(), 14 | }), 15 | }); 16 | 17 | /** 18 | * This type is generated using createMarketingEmailSchema and can be used 19 | * as express Request type generic 20 | * 21 | * @author tharun634 22 | */ 23 | export type CreateMarketingEmailSchema = z.TypeOf; 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor.yml: -------------------------------------------------------------------------------- 1 | name: "♻ Refactor Code" 2 | description: "Refactor your code" 3 | title: "[REFACTOR] :recycle: " 4 | labels: ["refactor"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A concise description of how we can refactor our code 11 | validations: 12 | required: false 13 | - type: textarea 14 | id: anything_else 15 | attributes: 16 | label: Anything else? 17 | description: | 18 | Links? References? Anything that will give us more context about the issue you are encountering! 19 | 20 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 21 | validations: 22 | required: false 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/test.yml: -------------------------------------------------------------------------------- 1 | name: "🧪 Test" 2 | description: "Issue related to tests" 3 | title: "[TEST] :test_tube: <title>" 4 | labels: ["tests"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A concise description if you want us to write new tests or check on failing tests 11 | validations: 12 | required: false 13 | - type: textarea 14 | id: anything_else 15 | attributes: 16 | label: Anything else? 17 | description: | 18 | Links? References? Anything that will give us more context about the issue you are encountering! 19 | 20 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 21 | validations: 22 | required: false 23 | -------------------------------------------------------------------------------- /src/middleware/requireSameUser.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | 4 | /** 5 | * fix: This middleware will check if the current user's request is for himself. 6 | * @author is-it-ayush 7 | */ 8 | export default function requireSameUser(req: Request, res: Response, next: NextFunction) { 9 | try { 10 | const { id } = req.params; 11 | const currentUser = res.locals.user; 12 | 13 | if (currentUser._id != id && currentUser.role !== "admin") { 14 | return res.status(StatusCodes.FORBIDDEN).json({ 15 | error: "Unauthorized", 16 | }); 17 | } 18 | return next(); 19 | } catch (err) { 20 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 21 | error: "Internal Server Error", 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs.yml: -------------------------------------------------------------------------------- 1 | name: "📝 Docs" 2 | description: "Found a security issue report it here" 3 | title: "[DOCS] :memo: <title>" 4 | labels: ["documentation"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A concise description if you want us to update or create new documentation 11 | validations: 12 | required: false 13 | - type: textarea 14 | id: anything_else 15 | attributes: 16 | label: Anything else? 17 | description: | 18 | Links? References? Anything that will give us more context about the issue you are encountering! 19 | 20 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 21 | validations: 22 | required: false 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "✨ Feature Request" 2 | description: "Suggest and idea for this project" 3 | title: "[FEAT] :sparkles: <title>" 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A concise description of what you want in this project 11 | validations: 12 | required: false 13 | - type: textarea 14 | id: anything_else 15 | attributes: 16 | label: Anything else? 17 | description: | 18 | Links? References? Anything that will give us more context about the issue you are encountering! 19 | 20 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 21 | validations: 22 | required: false 23 | -------------------------------------------------------------------------------- /src/middleware/requireAdminRole.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { User } from "../models/user.model"; 4 | 5 | /** 6 | * This middleware will check if the current user has admin role 7 | * 8 | * @author aayushchugh 9 | */ 10 | export default function requireAdminRole(_: Request, res: Response, next: NextFunction) { 11 | try { 12 | const currentUser: User = res.locals.user; 13 | 14 | if (currentUser == null || currentUser.role !== "admin") { 15 | return res.status(StatusCodes.FORBIDDEN).json({ 16 | error: "Insufficient rights", 17 | }); 18 | } 19 | return next(); 20 | } catch (err) { 21 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 22 | error: "Internal Server Error", 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | env: 7 | GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} 8 | GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} 9 | DB_URI: ${{ secrets.DB_URI }} 10 | EMAIL_ID: ${{ secrets.EMAIL_ID }} 11 | EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }} 12 | ACCESS_TOKEN_PRIVATE_KEY: ${{ secrets.ACCESS_TOKEN_PRIVATE_KEY }} 13 | ACCESS_TOKEN_PUBLIC_KEY: ${{ secrets.ACCESS_TOKEN_PUBLIC_KEY }} 14 | REFRESH_TOKEN_PRIVATE_KEY: ${{ secrets.REFRESH_TOKEN_PRIVATE_KEY }} 15 | REFRESH_TOKEN_PUBLIC_KEY: ${{ secrets.REFRESH_TOKEN_PUBLIC_KEY }} 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: "16" 21 | - run: yarn install 22 | - run: yarn test:ci 23 | -------------------------------------------------------------------------------- /src/services/marketingEmail.service.ts: -------------------------------------------------------------------------------- 1 | import { DocumentDefinition, FilterQuery } from "mongoose"; 2 | import MarketingEmailModel, { MarketingEmail } from "../models/marketingEmail.model"; 3 | 4 | /** 5 | * Create marketing email in database 6 | * @param {DocumentDefinition<MarketingEmail>} payload payload which will be used to create marketing email in database 7 | * 8 | * @author tharun634 9 | */ 10 | export function createMarketingEmailService(payload: DocumentDefinition<MarketingEmail>) { 11 | return MarketingEmailModel.create(payload); 12 | } 13 | 14 | /** 15 | * Find marketing emails from database with given query 16 | * @param {FilterQuery<MarketingEmail>} query filter which will be used to find marketing emails from database 17 | * 18 | * @author tharun634 19 | */ 20 | export function findMarketingEmailsService(query?: FilterQuery<MarketingEmail>) { 21 | return MarketingEmailModel.find(query || {}); 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | permissions: 8 | contents: write 9 | jobs: 10 | build-and-deploy: 11 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 🛎️ 15 | uses: actions/checkout@v3 16 | 17 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 18 | run: | 19 | yarn install 20 | yarn docs 21 | 22 | - name: Deploy 🚀 23 | uses: JamesIves/github-pages-deploy-action@v4 24 | with: 25 | folder: docs # The folder the action should deploy. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/security_issue.yml: -------------------------------------------------------------------------------- 1 | name: "🔒 Security Issue" 2 | description: "Found a security issue report it here" 3 | title: "[SECURITY] :lock: <title>" 4 | labels: ["security"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: A concise description of the security issue 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: how_to_solve 15 | attributes: 16 | label: How can we solve this 17 | description: Please tell us how can we solve this issue 18 | validations: 19 | required: false 20 | - type: textarea 21 | id: anything_else 22 | attributes: 23 | label: Anything else? 24 | description: | 25 | Links? References? Anything that will give us more context about the issue you are encountering! 26 | 27 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 28 | validations: 29 | required: false 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Multi Email 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/routes/user.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | patchUserHandler, 4 | getUnsubscribeUserFromMarketingEmailHandler, 5 | } from "../controllers/user.controller"; 6 | import deserializeUser from "../middleware/deserializeUser.middleware"; 7 | import requireSameUser from "../middleware/requireSameUser.middleware"; 8 | import validateRequest from "../middleware/validateRequest.middleware"; 9 | import { 10 | patchUserSchema, 11 | getUnsubscribeUserFromMarketingEmailSchema, 12 | } from "../schemas/user.schema"; 13 | 14 | const userRouter = Router(); 15 | 16 | // NOTE: all routes defined with `userRouter` will be pre-fixed with `/api` 17 | 18 | /** 19 | * This route does following things 20 | * PATCH -> update user's username 21 | * @author aayushchugh 22 | */ 23 | userRouter 24 | .route("/users/:id") 25 | .patch(validateRequest(patchUserSchema), deserializeUser, requireSameUser, patchUserHandler); 26 | 27 | userRouter.get( 28 | "/users/:id/marketing-emails/unsubscribe", 29 | validateRequest(getUnsubscribeUserFromMarketingEmailSchema), 30 | getUnsubscribeUserFromMarketingEmailHandler, 31 | ); 32 | export default userRouter; 33 | -------------------------------------------------------------------------------- /src/utils/email.util.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { SendMailOptions, SentMessageInfo, createTransport } from "nodemailer"; 3 | 4 | config(); 5 | 6 | const user = process.env.EMAIL_ID as string; 7 | const pass = process.env.EMAIL_PASSWORD as string; 8 | const smtp = process.env.EMAIL_SMTP as string; 9 | 10 | const transporter = createTransport({ 11 | host: smtp, 12 | port: 587, 13 | secure: false, 14 | requireTLS: true, 15 | auth: { 16 | user, 17 | pass, 18 | }, 19 | tls: { 20 | ciphers: "SSLv3", 21 | }, 22 | }); 23 | 24 | /** 25 | * Send Email using nodemailer 26 | * @param {string} to Email id to send email 27 | * @param {string} subject Subject of email 28 | * @param {string} html content of email 29 | * @return sent message info 30 | * @author aayushchugh 31 | */ 32 | export function sendEmail( 33 | to: string, 34 | subject: string, 35 | html: string, 36 | ): Promise<SentMessageInfo> | undefined { 37 | if (process.env.NODE_ENV?.trim() === "test") return; 38 | 39 | const mailOptions: SendMailOptions = { 40 | from: { 41 | name: "Multi Email", 42 | address: user, 43 | }, 44 | to, 45 | subject, 46 | html, 47 | }; 48 | 49 | return transporter.sendMail(mailOptions); 50 | } 51 | -------------------------------------------------------------------------------- /bin/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | /* eslint-disable node/shebang */ 4 | /* eslint-disable node/no-sync */ 5 | 6 | import yargs from "yargs"; 7 | import mongoose from "mongoose"; 8 | import "dotenv/config"; 9 | import { exit } from "process"; 10 | import { createUserService } from "../src/services/user.service"; 11 | 12 | const { email, username, password } = yargs(process.argv.slice(2)) 13 | .usage("multi-email-admin -e <email> -u <username> -p <password>") 14 | .options({ 15 | email: { type: "string", demandOption: true, alias: "e" }, 16 | username: { type: "string", demandOption: true, alias: "u" }, 17 | password: { type: "string", demandOption: true, alias: "p" }, 18 | }) 19 | .parseSync(); 20 | 21 | mongoose.connect(process.env.DB_URI as string, () => { 22 | createUserService({ 23 | role: "admin", 24 | username, 25 | email, 26 | verified: true, 27 | password, 28 | accepted_terms_and_conditions: true, 29 | receive_marketing_emails: true, 30 | }) 31 | .then(() => { 32 | console.log("✔ Admin created successfully"); 33 | 34 | exit(0); 35 | }) 36 | .catch((err) => { 37 | console.log(err); 38 | if (err) console.log("❌ Admin user already exists"); 39 | 40 | exit(1); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/middleware/getCurrentConnectedService.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { User } from "../models/user.model"; 4 | 5 | /** 6 | * This middleware will find current connected service from current user 7 | * and save that to `res.locals.currentConnectedService` 8 | * 9 | * @param req express request 10 | * @param res express response 11 | * @param next express next function 12 | * 13 | * @author aayushchugh 14 | */ 15 | const getCurrentConnectedService = (req: Request, res: Response, next: NextFunction) => { 16 | try { 17 | const user = res.locals.user as User; 18 | const { email } = req.params; 19 | 20 | const currentConnectedService = user.connected_services.find( 21 | (service) => service.email === email, 22 | ); 23 | 24 | if (!currentConnectedService) { 25 | return res.status(StatusCodes.NOT_FOUND).json({ 26 | error: "Account not connected", 27 | }); 28 | } 29 | 30 | res.locals.currentConnectedService = currentConnectedService; 31 | next(); 32 | } catch (err) { 33 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 34 | error: "Internal Server Error", 35 | }); 36 | } 37 | }; 38 | 39 | export default getCurrentConnectedService; 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug report" 2 | description: "File a bug/issue" 3 | title: "[BUG] :bug: <title>" 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | id: current_behaviour 8 | attributes: 9 | label: Current Behaviour 10 | description: A concise description of what you are currently experiencing 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: expected_behaviour 15 | attributes: 16 | label: Expected Behaviour 17 | description: A concise description of what is expected to happen 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: steps_reproduce 22 | attributes: 23 | label: Steps To Reproduce 24 | description: Steps to reproduce the behavior. 25 | placeholder: | 26 | 1. Go to... 27 | 2. Click on... 28 | 3. See error... 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: anything_else 33 | attributes: 34 | label: Anything else? 35 | description: | 36 | Links? References? Anything that will give us more context about the issue you are encountering! 37 | 38 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 39 | validations: 40 | required: false 41 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | <!-- -------------------------- Required section --------------------------- --> 2 | 3 | ### Description 4 | 5 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 6 | 7 | ### Checklist: 8 | 9 | <!-- pull request can be still created if all tasks are not completed --> 10 | 11 | - [ ] This pull request follow our contribution guidelines 12 | - [ ] I have performed a self-review of my own code 13 | - [ ] I have commented my code, particularly in hard-to-understand areas 14 | - [ ] I have made corresponding changes to the documentation (postman and wiki) 15 | - [ ] I have written tests for the code 16 | - [ ] I have updated .env.example file for new .env variables 17 | 18 | <!-- -------------------------- Optional section --------------------------- --> 19 | 20 | ### What is current behavior? 21 | 22 | <!-- You can add issue number --> 23 | 24 | Closes #(issue_number) 25 | 26 | ### What is new behavior? 27 | 28 | <!-- You can attach gif, screenshots, etc --> 29 | 30 | ### Breaking Changes? 31 | 32 | <!-- If this PR introduce any breaking changes then please list them here --> 33 | 34 | ### Additional information 35 | 36 | <!-- Links? References? Anything that will give us more context about the pull request --> 37 | -------------------------------------------------------------------------------- /src/middleware/deserializeUser.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { findUserByIdService } from "../services/user.service"; 4 | import { verifyJWT } from "../utils/jwt.util"; 5 | 6 | /** 7 | * This function will decode user from `authorization` header and add 8 | * it to `res.locals` as `res.locals.user` 9 | * 10 | * @param req request 11 | * @param res response 12 | * 13 | * @author aayushchugh 14 | */ 15 | const deserializeUser = async (req: Request, res: Response, next: NextFunction) => { 16 | const accessToken = (req.headers.authorization || "").replace(/^Bearer\s/, ""); 17 | 18 | if (!accessToken) { 19 | return res.status(StatusCodes.UNAUTHORIZED).json({ 20 | error: "User is not logged in", 21 | }); 22 | } 23 | 24 | const decoded = await verifyJWT<{ _id: string }>(accessToken, "ACCESS_TOKEN_PUBLIC_KEY"); 25 | 26 | if (decoded) { 27 | const user = await findUserByIdService(decoded._id); 28 | res.locals.user = user; 29 | } else { 30 | res.locals.user = null; 31 | 32 | return res.status(StatusCodes.FORBIDDEN).json({ 33 | // WARNING: Do not change this message because it will be used by frontend for condition 34 | error: "User is not logged in or access_token is expired", 35 | }); 36 | } 37 | 38 | return next(); 39 | }; 40 | 41 | export default deserializeUser; 42 | -------------------------------------------------------------------------------- /src/routes/ticket.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | createTicketHandler, 4 | deleteTicketHandler, 5 | getAllTicketsHandler, 6 | patchTicketStatusHandler, 7 | } from "../controllers/ticket.controller"; 8 | import requireAdminRole from "../middleware/requireAdminRole.middleware"; 9 | import validateRequest from "../middleware/validateRequest.middleware"; 10 | import { 11 | createTicketSchema, 12 | deleteTicketSchema, 13 | patchTicketStatusSchema, 14 | } from "../schemas/ticket.schema"; 15 | 16 | const ticketRouter: Router = Router(); 17 | 18 | // NOTE: all routes defined with `ticketRouter` will be pre-fixed with `/api` 19 | 20 | /** 21 | * This route will do following 22 | * POST -> create new ticket 23 | * GET -> get all tickets (protected for admin) 24 | * 25 | * @author aayushchugh 26 | */ 27 | ticketRouter 28 | .route("/tickets") 29 | .post(validateRequest(createTicketSchema), createTicketHandler) 30 | .get(requireAdminRole, getAllTicketsHandler); 31 | 32 | /** 33 | * This route will do following 34 | * PATCH -> update ticket status (protected for admin) 35 | * DELETE -> delete ticket (protected for admin) 36 | * 37 | * @author aayushchugh 38 | */ 39 | ticketRouter 40 | .route("/tickets/:id") 41 | .delete(requireAdminRole, validateRequest(deleteTicketSchema), deleteTicketHandler) 42 | .patch(requireAdminRole, validateRequest(patchTicketStatusSchema), patchTicketStatusHandler); 43 | 44 | export default ticketRouter; 45 | -------------------------------------------------------------------------------- /src/utils/jwt.util.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { sign, SignOptions, verify } from "jsonwebtoken"; 3 | 4 | config(); 5 | 6 | /** 7 | * Create a JWT token from given payload 8 | * @param payload what should be signed into JWT 9 | * @param keyName which key should be used for signing token 10 | * @param options additional options for JWT library 11 | * @author aayushchugh, is-it-ayush 12 | */ 13 | export async function signJWT( 14 | payload: object, 15 | keyName: "ACCESS_TOKEN_PRIVATE_KEY" | "REFRESH_TOKEN_PRIVATE_KEY", 16 | options?: SignOptions, 17 | ): Promise<string> { 18 | const privateKey = process.env[keyName] as string; 19 | const token = (await sign(payload, privateKey, { 20 | ...(options && options), 21 | algorithm: "RS256", 22 | })) as string; 23 | 24 | return token; 25 | } 26 | 27 | /** 28 | * Verify and decode JWT token 29 | * @param token encoded token which will be verified/decoded 30 | * @param keyName key which will be used to decode token (should be same as private key) 31 | * @author aayushchugh, is-it-ayush 32 | */ 33 | export async function verifyJWT<T>( 34 | token: string, 35 | keyName: "ACCESS_TOKEN_PUBLIC_KEY" | "REFRESH_TOKEN_PUBLIC_KEY", 36 | ): Promise<T | null> { 37 | const publicKey = process.env[keyName] as string; 38 | let decoded: T; 39 | try { 40 | decoded = (await verify(token, publicKey, { 41 | algorithms: ["RS256"], 42 | })) as T; 43 | 44 | return decoded; 45 | } catch (error) { 46 | return null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/middleware/validateRequest.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { AnyZodObject, ZodError } from "zod"; 4 | 5 | /** 6 | * This middleware will validate the request body using zod schema 7 | * @param schema schema created using zod 8 | * 9 | * @author aayushchugh 10 | */ 11 | function validateRequest(schema: AnyZodObject) { 12 | return (req: Request, res: Response, next: NextFunction) => { 13 | try { 14 | schema.parse({ body: req.body, query: req.query, params: req.params }); 15 | 16 | return next(); 17 | } catch (err) { 18 | if (err instanceof ZodError) { 19 | switch (err.errors[0].code) { 20 | case "invalid_string": 21 | res.status(StatusCodes.UNPROCESSABLE_ENTITY); 22 | break; 23 | 24 | case "invalid_type": 25 | res.status(StatusCodes.BAD_REQUEST); 26 | break; 27 | 28 | case "invalid_enum_value": 29 | res.status(StatusCodes.UNPROCESSABLE_ENTITY); 30 | break; 31 | 32 | case "custom": 33 | res.status(StatusCodes.UNAUTHORIZED); 34 | break; 35 | 36 | default: 37 | res.status(StatusCodes.BAD_REQUEST); 38 | break; 39 | } 40 | return res.json({ 41 | error: err.errors[0].message, 42 | }); 43 | } 44 | 45 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 46 | error: "Internal server error", 47 | }); 48 | } 49 | }; 50 | } 51 | 52 | export default validateRequest; 53 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import express, { Application } from "express"; 3 | import cors from "cors"; 4 | import mongoose from "mongoose"; 5 | import helmet from "helmet"; 6 | import logger from "./utils/logger.util"; 7 | import authRouter from "./routes/auth.routes"; 8 | import userRouter from "./routes/user.routes"; 9 | import ticketRouter from "./routes/ticket.routes"; 10 | import rateLimiter from "./middleware/rateLimiter.middleware"; 11 | import adminRouter from "./routes/admin.routes"; 12 | import mailRouter from "./routes/mail.routes"; 13 | import deserializeUser from "./middleware/deserializeUser.middleware"; 14 | import requireAdminRole from "./middleware/requireAdminRole.middleware"; 15 | 16 | config(); 17 | 18 | const app: Application = express(); 19 | 20 | app.use(cors()); 21 | app.use(helmet()); 22 | app.use(rateLimiter); 23 | app.use(express.urlencoded({ extended: true })); 24 | app.use(express.json()); 25 | 26 | app.use("/api", authRouter); 27 | app.use("/api", userRouter); 28 | app.use("/api", ticketRouter); 29 | app.use("/api", deserializeUser, mailRouter); 30 | app.use("/api", deserializeUser, requireAdminRole, adminRouter); 31 | logger.info("Current Environment: " + process.env.NODE_ENV); 32 | 33 | if (process.env.NODE_ENV?.trim() !== "test") { 34 | mongoose.connect(process.env.DB_URI as string, () => { 35 | const PORT = process.env.PORT || 3001; 36 | logger.info("Connected to Database!"); 37 | 38 | app.listen(PORT, () => { 39 | logger.info(`Server listening on http://localhost:${PORT}`); 40 | }); 41 | }); 42 | } 43 | 44 | module.exports = app; 45 | -------------------------------------------------------------------------------- /src/routes/mail.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | deleteEmailFromGmailHandler, 4 | getDraftsFromGmailHandler, 5 | getEmailFromGmailHandler, 6 | getEmailsFromGmailHandler, 7 | postSendGmailHandler, 8 | } from "../controllers/mail.controller"; 9 | import getCurrentConnectedService from "../middleware/getCurrentConnectedService.middleware"; 10 | import requireSameUser from "../middleware/requireSameUser.middleware"; 11 | import { 12 | deleteEmailFromGmailSchema, 13 | getDraftsFromGmailSchema, 14 | getEmailFromGmailSchema, 15 | getEmailsFromGmailSchema, 16 | postSendGmailSchema, 17 | } from "../schemas/mail.schema"; 18 | import validateRequest from "../middleware/validateRequest.middleware"; 19 | import deserializeUser from "../middleware/deserializeUser.middleware"; 20 | 21 | const mailRouter = Router(); 22 | 23 | mailRouter 24 | .route("/mail/:id/gmail/:email") 25 | .get( 26 | requireSameUser, 27 | validateRequest(getEmailsFromGmailSchema), 28 | getCurrentConnectedService, 29 | getEmailsFromGmailHandler, 30 | ) 31 | .post( 32 | requireSameUser, 33 | validateRequest(postSendGmailSchema), 34 | getCurrentConnectedService, 35 | postSendGmailHandler, 36 | ); 37 | 38 | /** 39 | * This route does following things 40 | * GET -> fetch single message from Gmail 41 | * DELETE -> delete single message from Gmail 42 | * 43 | * @author tharun634, aayushchugh 44 | */ 45 | mailRouter 46 | .route("/mail/:id/gmail/:email/:messageId") 47 | .get( 48 | validateRequest(getEmailFromGmailSchema), 49 | deserializeUser, 50 | getCurrentConnectedService, 51 | requireSameUser, 52 | getEmailFromGmailHandler, 53 | ) 54 | .delete( 55 | validateRequest(deleteEmailFromGmailSchema), 56 | deserializeUser, 57 | getCurrentConnectedService, 58 | requireSameUser, 59 | deleteEmailFromGmailHandler, 60 | ); 61 | 62 | /** 63 | * This route does following things 64 | * GET -> fetch drafts from Gmail 65 | * 66 | * @author tharun634 67 | */ 68 | mailRouter.get( 69 | "/mail/:id/gmail/:email/drafts", 70 | requireSameUser, 71 | validateRequest(getDraftsFromGmailSchema), 72 | getCurrentConnectedService, 73 | getDraftsFromGmailHandler, 74 | ); 75 | 76 | export default mailRouter; 77 | -------------------------------------------------------------------------------- /src/services/ticket.service.ts: -------------------------------------------------------------------------------- 1 | import { FilterQuery, DocumentDefinition, UpdateQuery } from "mongoose"; 2 | import TicketModel, { Ticket } from "../models/ticket.model"; 3 | 4 | /** 5 | * Find tickets from database with given query 6 | * @param {FilterQuery<Ticket>} query filter which will be used to find tickets from database 7 | * 8 | * @author aayushchugh 9 | */ 10 | export function findTicketsService(query?: FilterQuery<Ticket>) { 11 | return TicketModel.find(query || {}); 12 | } 13 | 14 | /** 15 | * Find ticket from database with given id 16 | * @param {string} id id of the ticket which will be used to find ticket from database 17 | * 18 | * @author aayushchugh 19 | */ 20 | export function findTicketsByIdService(id: string) { 21 | return TicketModel.findById(id); 22 | } 23 | 24 | /** 25 | * Create ticket in database 26 | * @param {DocumentDefinition<Ticket>} payload payload which will be used to create ticket in database 27 | * 28 | * @author aayushchugh 29 | */ 30 | export function createTicketService(payload: DocumentDefinition<Ticket>) { 31 | return TicketModel.create(payload); 32 | } 33 | 34 | /** 35 | * It takes an id and a payload, and then it updates the ticket with the given id with the given 36 | * payload 37 | * @param {string} id - string - the id of the ticket to update 38 | * @param payload - UpdateQuery<Ticket> 39 | * @returns A promise that resolves to the updated ticket. 40 | * 41 | * @author aayushchugh 42 | */ 43 | export function updateTicketByIdService(id: string, payload: UpdateQuery<Ticket>) { 44 | return TicketModel.findByIdAndUpdate(id, payload, { new: true }); 45 | } 46 | 47 | /** 48 | * It deletes all the tickets that match the query 49 | * @param query - filter which will be used to delete tickets from database 50 | * 51 | * @author aayushchugh 52 | */ 53 | export function deleteAllTicketsService(query: FilterQuery<Ticket>) { 54 | return TicketModel.deleteMany(query); 55 | } 56 | 57 | /** 58 | * It takes an id as a parameter, and returns a promise that will resolve to the deleted ticket 59 | * @param {string} id - The id of the ticket to be deleted. 60 | * @returns A promise that will resolve to the deleted ticket. 61 | * 62 | * @author aayushchugh 63 | */ 64 | export function deleteTicketByIdService(id: string) { 65 | return TicketModel.findByIdAndDelete(id); 66 | } 67 | -------------------------------------------------------------------------------- /src/schemas/ticket.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * This schema is used to validate `POST /ticket` request 5 | * 6 | * @constant 7 | * @author aayushchugh 8 | */ 9 | export const createTicketSchema = z.object({ 10 | body: z.object({ 11 | name: z 12 | .string({ required_error: "Name is required" }) 13 | .min(3, "Name must be minimum 3 characters") 14 | .max(50, "Name can not be longer than 50 characters"), 15 | 16 | email: z 17 | .string({ required_error: "Email is required" }) 18 | .email("Please enter a valid email"), 19 | 20 | subject: z 21 | .string({ required_error: "Title is required" }) 22 | .min(10, "Title must be minimum 10 characters") 23 | .max(300, "Title can not be longer than 300 characters"), 24 | 25 | message: z 26 | .string({ required_error: "Description is required" }) 27 | .max(1000, "Description can not be longer than 1000 characters"), 28 | }), 29 | }); 30 | 31 | /** 32 | * This type is generated using createTicketSchema and can be used 33 | * as express Request type generic 34 | * 35 | * @author aayushchugh 36 | */ 37 | export type CreateTicketSchema = z.TypeOf<typeof createTicketSchema>; 38 | 39 | /** 40 | * This schema is used to validate `PATCH /tickets/:id` request 41 | * 42 | * @author aayushchugh 43 | */ 44 | export const patchTicketStatusSchema = z.object({ 45 | body: z.object({ 46 | status: z.enum(["new", "in-progress", "solved"], { required_error: "Status is required" }), 47 | }), 48 | params: z.object({ 49 | id: z.string({ required_error: "Id is required" }), 50 | }), 51 | }); 52 | 53 | /** 54 | * This type is generated using `patchTicketSchema` and can be used 55 | * as express Request type generic 56 | * 57 | * @author aayushchugh 58 | */ 59 | export type PatchTicketStatusSchema = z.TypeOf<typeof patchTicketStatusSchema>; 60 | 61 | /** 62 | * This schema is used to validate `DELETE /tickets/:id` request 63 | * 64 | * @author aayushchugh 65 | */ 66 | export const deleteTicketSchema = z.object({ 67 | params: z.object({ 68 | id: z.string({ required_error: "Id is required" }), 69 | }), 70 | }); 71 | 72 | /** 73 | * This type is generated using `deleteTicketSchema` and can be used 74 | * as express Request type generic 75 | * 76 | * @author aayushchugh 77 | */ 78 | export type DeleteTicketSchema = z.TypeOf<typeof deleteTicketSchema>; 79 | -------------------------------------------------------------------------------- /src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { genSalt, hash, compare } from "bcrypt"; 2 | import { pre, prop, index, getModelForClass, Severity } from "@typegoose/typegoose"; 3 | import { generateRandomOTP } from "../utils/otp.util"; 4 | 5 | export const userModalPrivateFields = [ 6 | "password", 7 | "__v", 8 | "verification_code", 9 | "password_reset_code", 10 | "connected_services", 11 | ]; 12 | 13 | export class ConnectedServices { 14 | @prop() 15 | // TODO: make service union type when more services are added 16 | service: "google"; 17 | 18 | @prop() 19 | refresh_token: string; 20 | 21 | @prop() 22 | access_token: string; 23 | 24 | @prop() 25 | email: string; 26 | } 27 | 28 | @index({ uid: 1, email: 1, username: 1 }) 29 | @pre<User>("save", async function (next) { 30 | // hash password before user is created or updated 31 | if (!this.isModified("password")) { 32 | return next(); 33 | } 34 | 35 | const salt = await genSalt(10); 36 | const hashPassword = await hash(this.password, salt); 37 | this.password = hashPassword; 38 | next(); 39 | }) 40 | export class User { 41 | @prop({ required: true, default: "user" }) 42 | public role: "admin" | "user"; 43 | 44 | @prop({ required: true, unique: true }) 45 | public email: string; 46 | 47 | @prop({ required: true, unique: true }) 48 | public username: string; 49 | 50 | @prop({ required: true }) 51 | public password: string; 52 | 53 | @prop({ required: true, default: false }) 54 | public verified: boolean; 55 | 56 | @prop({ required: true, default: () => generateRandomOTP() }) 57 | public verification_code: number; 58 | 59 | @prop() 60 | public password_reset_code: number | null; 61 | 62 | @prop({ required: true }) 63 | public accepted_terms_and_conditions: boolean; 64 | 65 | @prop({ required: true, default: false }) 66 | public receive_marketing_emails: boolean; 67 | 68 | @prop({ default: [] }) 69 | public connected_services: [ConnectedServices]; 70 | 71 | /** 72 | * Check if the password is correct or not 73 | * @param password password to compare with the user password 74 | */ 75 | public comparePassword(password: string) { 76 | return compare(password, this.password); 77 | } 78 | } 79 | 80 | const UserModel = getModelForClass(User, { 81 | schemaOptions: { timestamps: true }, 82 | // setting allow mixed so that we can set password string or null 83 | options: { allowMixed: Severity.ALLOW }, 84 | }); 85 | 86 | export default UserModel; 87 | -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { DocumentType } from "@typegoose/typegoose"; 2 | import { User, userModalPrivateFields } from "../models/user.model"; 3 | import { omit } from "lodash"; 4 | import { signJWT } from "../utils/jwt.util"; 5 | import { createSessionService } from "./session.service"; 6 | import axios from "axios"; 7 | import qs from "qs"; 8 | import logger from "../utils/logger.util"; 9 | 10 | /** 11 | * Create a access token for given user 12 | * @param user this is user which will be signed into JWT token 13 | * 14 | * @author aayushchugh 15 | */ 16 | export const signAccessTokenService = (user: DocumentType<User>) => { 17 | const payload = omit(user.toJSON(), userModalPrivateFields); 18 | 19 | return signJWT(payload, "ACCESS_TOKEN_PRIVATE_KEY", { 20 | expiresIn: "15m", 21 | }); 22 | }; 23 | 24 | /** 25 | * This service will create a refreshToken and create new session in database 26 | * @param userId id of user for which refreshToken will be generated 27 | * 28 | * @author aayushchugh 29 | */ 30 | export const signRefreshTokenService = async (userId: string) => { 31 | const session = await createSessionService(userId); 32 | 33 | return signJWT({ session: session._id }, "REFRESH_TOKEN_PRIVATE_KEY", { 34 | expiresIn: "15d", 35 | }); 36 | }; 37 | 38 | interface IGoogleTokenResult { 39 | access_token: string; 40 | refresh_token: string; 41 | expires_in: number; 42 | scope: string; 43 | id_token: string; 44 | } 45 | 46 | /** 47 | * This service will get tokens from Google api 48 | * @param code code received from Google consent screen 49 | * 50 | * @author aayushchugh 51 | */ 52 | export const getGoogleOAuthTokensService = async (code: string): Promise<IGoogleTokenResult> => { 53 | const url = "https://oauth2.googleapis.com/token"; 54 | const clientId = process.env.GOOGLE_CLIENT_ID as string; 55 | const clientSecret = process.env.GOOGLE_CLIENT_SECRET as string; 56 | const redirectUrl = process.env.GOOGLE_REDIRECT_URL as string; 57 | 58 | const values = { 59 | code, 60 | client_id: clientId, 61 | client_secret: clientSecret, 62 | redirect_uri: redirectUrl, 63 | grant_type: "authorization_code", 64 | }; 65 | 66 | try { 67 | const res = await axios.post(url, qs.stringify(values), { 68 | headers: { 69 | "Content-Type": "application/x-www-form-urlencoded", 70 | }, 71 | }); 72 | 73 | return res.data; 74 | } catch (err: any) { 75 | logger.error(err); 76 | throw new Error(err.message); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 10 | 11 | ## Issues and PRs 12 | 13 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 14 | 15 | We'd also love PRs. If you're thinking of a large PR, we advise opening up an issue first to talk about it, though! Look at the links below if you're not sure how to open a PR. 16 | 17 | 1. [Fork][fork] and clone the repository. 18 | 2. Configure and install the dependencies: `yarn install`. 19 | 3. Create a new branch: `git checkout -b my-branch-name`. 20 | 4. Work on your issue/feature 21 | 5. Before committing the changes run tests using `yarn test` 22 | 6. Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for writing commit messages 23 | 7. Push to your fork and [submit a pull request][pr] in `main` branch 24 | 8. Pat your self on the back and wait for your pull request to be approved and merged. 25 | 26 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 27 | 28 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 29 | - Follow [Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). 30 | 31 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocked you. (just mark them as draft) 32 | 33 | ## Resources 34 | 35 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 36 | - [About Pull Requests](https://help.github.com/articles/about-pull-requests/) 37 | - [GitHub Help](https://help.github.com) 38 | - [Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) 39 | - [Conventional commits vscode extension](https://marketplace.visualstudio.com/items?itemName=vivaxy.vscode-conventional-commits) 40 | - [Github issues and pull requests vscode extension](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) 41 | -------------------------------------------------------------------------------- /src/routes/admin.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | createMarketingEmailHandler, 4 | getAllMarketingEmailsHandler, 5 | } from "../controllers/marketingEmail.controller"; 6 | import { 7 | deleteUserHandler, 8 | getAllUsersHandler, 9 | getSingleUserHandler, 10 | patchMarkUserAdminHandler, 11 | patchMarkUserVerifiedHandler, 12 | } from "../controllers/user.controller"; 13 | import validateRequest from "../middleware/validateRequest.middleware"; 14 | import { 15 | deleteUserSchema, 16 | getAllUsersSchema, 17 | getSingleUserSchema, 18 | patchMarkUserAdminSchema, 19 | patchMarkUserVerifiedSchema, 20 | } from "../schemas/user.schema"; 21 | import { createMarketingEmailSchema } from "../schemas/marketingEmail.schema"; 22 | 23 | const adminRouter: Router = Router(); 24 | 25 | // NOTE: all routes defined with `adminRouter` will be pre-fixed with `/api` 26 | // NOTE: all routes in this file are protected by `deserializeUser` and `requireAdminRole` middlewares 27 | 28 | /** 29 | * This route will get all users 30 | * protected for admin 31 | * 32 | * @author aayushchugh, is-it-ayush 33 | */ 34 | adminRouter.get("/admin/users", validateRequest(getAllUsersSchema), getAllUsersHandler); 35 | 36 | /** 37 | * This route will mark user as verified. 38 | * protected for admin 39 | * 40 | * @author aayushchugh, is-it-ayush 41 | */ 42 | adminRouter.patch( 43 | "/admin/users/:id/mark-verified", 44 | validateRequest(patchMarkUserVerifiedSchema), 45 | patchMarkUserVerifiedHandler, 46 | ); 47 | 48 | /** 49 | * protected for admin 50 | * This route will do following things 51 | * 52 | * GET -> get single user 53 | * DELETE -> delete single user 54 | * 55 | * @author aayushchugh, is-it-ayush 56 | */ 57 | adminRouter 58 | .route("/admin/users/:id") 59 | .get(validateRequest(getSingleUserSchema), getSingleUserHandler) 60 | .delete(validateRequest(deleteUserSchema), deleteUserHandler); 61 | 62 | /** 63 | * This route will send marketing emails 64 | * 65 | * @author aayushchugh, tharun634 66 | */ 67 | adminRouter 68 | .route("/admin/marketing-emails") 69 | .get(getAllMarketingEmailsHandler) 70 | .post(validateRequest(createMarketingEmailSchema), createMarketingEmailHandler); 71 | 72 | /** 73 | * This route does following things 74 | * PATCH -> mark user as admin 75 | * 76 | * @author tharun634 77 | */ 78 | adminRouter 79 | .route("/admin/users/:id/mark-admin") 80 | .patch(validateRequest(patchMarkUserAdminSchema), patchMarkUserAdminHandler); 81 | 82 | export default adminRouter; 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multiemail-backend", 3 | "version": "1.0.0", 4 | "main": "src/app.ts", 5 | "author": "Multi Email", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "yarn && tsnd --exit-child --transpile-only src/app.ts", 9 | "build": "yarn && tsc --build", 10 | "docs": "typedoc", 11 | "prepare": "husky install", 12 | "test": "set NODE_ENV=test && jest --forceExit && set NODE_ENV=", 13 | "test:ci": "NODE_ENV=test jest --forceExit" 14 | }, 15 | "bin": { 16 | "multi-email-admin": "./build/bin/index.js" 17 | }, 18 | "dependencies": { 19 | "@typegoose/typegoose": "^10.0.0", 20 | "@types/axios": "^0.14.0", 21 | "@types/lodash": "^4.14.186", 22 | "@types/nodemailer": "^6.4.6", 23 | "axios": "^1.1.2", 24 | "bcrypt": "^5.0.1", 25 | "body-parser": "^1.20.0", 26 | "cors": "^2.8.5", 27 | "dotenv": "^16.0.2", 28 | "express": "^4.18.1", 29 | "express-rate-limit": "^6.6.0", 30 | "helmet": "^6.0.0", 31 | "http-status-codes": "^2.2.0", 32 | "isomorphic-dompurify": "^1.0.0", 33 | "jsonwebtoken": "^8.5.1", 34 | "lodash": "^4.17.21", 35 | "mongoose": "^7.0.0", 36 | "nodemailer": "^6.8.0", 37 | "nodemon": "^2.0.20", 38 | "pino": "^8.6.0", 39 | "pino-pretty": "^10.0.0", 40 | "qs": "^6.11.0", 41 | "yargs": "^17.6.0", 42 | "zod": "^3.19.1" 43 | }, 44 | "devDependencies": { 45 | "@commitlint/cli": "17.4.4", 46 | "@commitlint/config-conventional": "17.4.4", 47 | "@types/bcrypt": "5.0.0", 48 | "@types/cors": "2.8.14", 49 | "@types/dotenv": "8.2.0", 50 | "@types/express": "4.17.18", 51 | "@types/jsonwebtoken": "9.0.0", 52 | "@typescript-eslint/eslint-plugin": "5.62.0", 53 | "@typescript-eslint/parser": "5.62.0", 54 | "add": "2.0.6", 55 | "all-contributors-cli": "6.24.0", 56 | "commitlint": "17.4.4", 57 | "cz-conventional-changelog": "3.3.0", 58 | "eslint": "8.50.0", 59 | "eslint-config-google": "0.14.0", 60 | "eslint-config-prettier": "8.10.0", 61 | "eslint-import-resolver-typescript": "3.5.3", 62 | "eslint-plugin-import": "2.27.5", 63 | "eslint-plugin-jsonc": "2.6.0", 64 | "eslint-plugin-no-secrets": "0.8.9", 65 | "eslint-plugin-no-unsanitized": "4.0.2", 66 | "eslint-plugin-node": "11.1.0", 67 | "eslint-plugin-pii": "1.0.2", 68 | "eslint-plugin-prettier": "4.2.1", 69 | "eslint-plugin-sonarjs": "0.18.0", 70 | "husky": "8.0.3", 71 | "jest": "29.7.0", 72 | "lint-staged": "^13.0.3", 73 | "mongodb-memory-server": "8.15.1", 74 | "prettier": "2.8.8", 75 | "prettier-eslint": "15.0.1", 76 | "supertest": "6.3.3", 77 | "ts-jest": "29.0.5", 78 | "ts-node": "10.9.1", 79 | "ts-node-dev": "2.0.0", 80 | "typedoc": "0.23.26", 81 | "typescript": "4.9.5" 82 | }, 83 | "lint-staged": { 84 | "**/*.{js,jsx,ts,tsx}": [ 85 | "npx prettier --write", 86 | "npx eslint --fix" 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "plugins": [ 9 | "@typescript-eslint", 10 | "prettier", 11 | "sonarjs", 12 | "no-secrets", 13 | "jsonc", 14 | "pii", 15 | "no-unsanitized" 16 | ], 17 | "extends": [ 18 | "google", 19 | "prettier", 20 | "plugin:sonarjs/recommended", 21 | "plugin:node/recommended", 22 | "plugin:jsonc/base", 23 | "plugin:pii/recommended", 24 | "plugin:no-unsanitized/DOM", 25 | "plugin:prettier/recommended", 26 | "plugin:@typescript-eslint/recommended" 27 | ], 28 | "parserOptions": { 29 | "ecmaVersion": 2020, 30 | "sourceType": "module" 31 | }, 32 | "settings": { 33 | "import/resolver": { 34 | "typescript": {} 35 | } 36 | }, 37 | "rules": { 38 | "semi": "error", 39 | "no-console": "warn", 40 | "no-tabs": "off", 41 | "indent": "off", 42 | "space-infix-ops": "off", 43 | "no-trailing-spaces": "error", 44 | "space-before-blocks": "error", 45 | "quotes": "off", 46 | "camelcase": "error", 47 | "node/no-unpublished-import": "off", 48 | "node/no-unsupported-features/es-syntax": "off", 49 | "node/no-missing-import": [ 50 | "error", 51 | { 52 | "allowModules": [], 53 | "resolvePaths": ["/path/to/a/modules/directory"], 54 | "tryExtensions": [".js", ".ts", ".json", ".node"] 55 | } 56 | ], 57 | "valid-jsdoc": "off", 58 | "node/handle-callback-err": "error", 59 | "node/no-path-concat": "error", 60 | "node/no-process-exit": "error", 61 | "node/global-require": "error", 62 | "node/no-sync": "error", 63 | "no-secrets/no-secrets": "error", 64 | "pii/no-email": "off", 65 | "no-invalid-this": "off", 66 | "spaced-comment": "off", 67 | "prefer-destructuring": ["error", { "object": true, "array": true }], 68 | "@typescript-eslint/no-explicit-any": "off", 69 | "no-unused-vars": "off", 70 | "@typescript-eslint/no-unused-vars": [ 71 | "warn", 72 | { "argsIgnorePattern": "req|res", "varsIgnorePattern": "prop" } 73 | ], 74 | "@typescript-eslint/ban-types": [ 75 | "error", 76 | { 77 | "types": { 78 | // un-ban a type that's banned by default 79 | "{}": false 80 | }, 81 | "extendDefaults": true 82 | } 83 | ], 84 | "linebreak-style": "off", 85 | "object-curly-spacing": "off", 86 | "comma-dangle": "off", 87 | "new-cap": "off", 88 | "require-await": "error", 89 | "require-jsdoc": "off", 90 | "sonarjs/cognitive-complexity": "off", 91 | "sonarjs/no-duplicate-string": "off", 92 | "sonarjs/no-identical-expressions": "off", 93 | "sonarjs/no-identical-functions": "off", 94 | "@typescript-eslint/quotes": ["error", "double"], 95 | "@typescript-eslint/explicit-function-return-type": "off", 96 | "prettier/prettier": [ 97 | "error", 98 | { 99 | "endOfLine": "auto", 100 | "useTabs": true 101 | } 102 | ] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BASE_URL="http://localhost:3001" 2 | DB_URI="mongodb://localhost:27017/multiemail" 3 | GOOGLE_CLIENT_ID="758327950938-90jskrnp9b8d2e6ljpqrstd8fdl2k9fljkhchasnnrnj8.apps.googleusercontent.com" 4 | GOOGLE_CLIENT_SECRET="GOCSPX-NL52LzLNzF6YGJxlAoeLAnGK-a6" 5 | GOOGLE_REDIRECT_URL="http://localhost:3001/api/auth/oauth/google/redirect" 6 | NODE_ENV="development" 7 | FRONTEND_URL="http://localhost:8000" 8 | EMAIL_ID="no-reply@multiemail.us" 9 | EMAIL_PASSWORD="mystrongpassword" 10 | EMAIL_SMTP="smtp.server.com" 11 | ACCESS_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- 12 | MIICXQIBAAKBgQC30IZxZn1Q3T5ey0F1ja5YTbNMYxCf2N/tOJtn5AsGbFGUnQ4a 13 | Hp7DPxuuoWaQXcnJhbIbdVMAuGcDtc4c4jP4MJzbmWmKVSVLkZLhPAq5+dqb7OW7 14 | nVyTnl0Vztu9O33AmlCY3AUEz7q786Y6LhTNou8MQajCfmHdTkFLp6i6qQIDAQAB 15 | AoGBAJ9RYqW5YlaLXHrHCvZ7lag9uHE1z/vr+rJehPv4AMJRcigwND/ZWFv8P98N 16 | T5tDXxmHAsef2hBexBLIKlyIhudYGO12QJGH94Ey6VoiiEQnxFNjTx0q7jAFvrum 17 | WsvNTIG5YkMQ9xuubDGWrjM3IF7WJvoxU1pEuJ0EmdAnzfmBAkEA2bTmmQqSWifg 18 | UgALkUp73K5k7CrPbISkQ/+JqHoMjyssQzXCoNHO1pLwMB0HWy2Z63447zovb4OI 19 | fC8cr1cGuQJBANglf9rzai4xCgREHAzfDEzTU5K4qHZb/loYmuehoKDkVXrW30jO 20 | KAbdn3eNWrGuS3XnU2m78dA01zJsOdwzW3ECQFQlktfmeSj1rsOjFtWCl5t1oLaT 21 | 2XaVUjSiKaAABKi1xDb6KY8laTAQvVurbLN2Tb7zG6iDseAFVBTD1O+E0KkCQQCC 22 | nS6E7gElBqdJ6qqUsJirOCzRhdrvIyox4ZqCDK6Xa0OoZn4pbcLMW1KJGRdMNcoN 23 | 5osGYvd+XOAJ7VKNmU9RAkBlnjWyERlq2y1nHhK8yICd5inDUvbAJpLms8HkpWvm 24 | rbmu1dkVfuErZXj46s3VPL1RVCWRmK7r2f7ETSrxGpDm 25 | -----END RSA PRIVATE KEY-----" 26 | ACCESS_TOKEN_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- 27 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC30IZxZn1Q3T5ey0F1ja5YTbNM 28 | YxCf2N/tOJtn5AsGbFGUnQ4aHp7DPxuuoWaQXcnJhbIbdVMAuGcDtc4c4jP4MJzb 29 | mWmKVSVLkZLhPAq5+dqb7OW7nVyTnl0Vztu9O33AmlCY3AUEz7q786Y6LhTNou8M 30 | QajCfmHdTkFLp6i6qQIDAQAB 31 | -----END PUBLIC KEY-----" 32 | REFRESH_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- 33 | MIICXQIBAAKBgQDZyxSr5ehSUQ8TpXyO9Qj7SjOEWle8q+pNLmQR27qZr6OOdbkq 34 | vKaKgroaF6thnHDP0khehsSlaioO/oqW73jNiskd9ergXZ2vKRziDOl+Mf4WvZ39 35 | IJp4vS+38kOyUJGNeOgwX26rCtIc1hY87hAuUATBeiQx/dQOJIcG2Dh57QIDAQAB 36 | AoGAK9YjYAITg9YK6dJqQr/xQdsKiX5BoJkdvNE0lR+b7Gkcy4TIc2CrSL/NQ4k2 37 | FpyNXFcf9966X+0BcCktrfmfX6icqOEZOh4XgyM5CDfpF4oE8aD8TfRelMwD0WME 38 | gtIsBaTA4BNgxT2xEsRa81mytxFetvwdQViSXRxsJN1UlCECQQDs6IOqHInZnLfw 39 | JOYSvp69xLA1vttNHO4ovxMXn3ZSv3FU08d7EE9gBvKU9wl56RN3IxdX1xq1o/N4 40 | kp3WSz0ZAkEA61g4D8wGONPb5wJH6yjidb990hr3MeVY2NuWznSwnYaAz29a5QMS 41 | +nQ2R0yAx1mbXB30riUBlo9Ieg3PAJ4p9QJBAItGJiFbpa7I81m6V4etiKUHfJAc 42 | I9CxsVFDA3ZfyK/c3EOCPUOb0w4hB3uLv4Zr/4WKm66IRquCNyArEZ9pnAECQEpR 43 | 2aJjc7OOc+tHtR52Es3MYxdunJGNM7mH3t/jycJ1L0hSigm4Js4g1OM/LYvGqGE2 44 | tIYp+Y5qQSEKK0yupeECQQDeIQ3OcjSeUZb7JA491UmGXgSfOHmzPVq0Seky9A1P 45 | vUtKxRBpd7HRyWuUJ+DNErjq+Oy8/gGRMJc+KUJElJfI 46 | -----END RSA PRIVATE KEY-----" 47 | REFRESH_TOKEN_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- 48 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZyxSr5ehSUQ8TpXyO9Qj7SjOE 49 | Wle8q+pNLmQR27qZr6OOdbkqvKaKgroaF6thnHDP0khehsSlaioO/oqW73jNiskd 50 | 9ergXZ2vKRziDOl+Mf4WvZ39IJp4vS+38kOyUJGNeOgwX26rCtIc1hY87hAuUATB 51 | eiQx/dQOJIcG2Dh57QIDAQAB 52 | -----END PUBLIC KEY-----" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | # Created by https://www.toptal.com/developers/gitignore/api/node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 4 | docs 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage/ 29 | *.info 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | build 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # vuepress v2.x temp and cache directory 110 | .temp 111 | 112 | # Docusaurus cache and generated files 113 | .docusaurus 114 | 115 | # Serverless directories 116 | .serverless/ 117 | 118 | # FuseBox cache 119 | .fusebox/ 120 | 121 | # DynamoDB Local files 122 | .dynamodb/ 123 | 124 | # TernJS port file 125 | .tern-port 126 | 127 | # Stores VSCode versions used for testing VSCode extensions 128 | .vscode-test 129 | 130 | # yarn v2 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .yarn/install-state.gz 135 | .pnp.* 136 | 137 | ### Node Patch ### 138 | # Serverless Webpack directories 139 | .webpack/ 140 | 141 | # Optional stylelint cache 142 | 143 | # SvelteKit build / generate output 144 | .svelte-kit 145 | 146 | # End of https://www.toptal.com/developers/gitignore/api/node 147 | 148 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 149 | 150 | -------------------------------------------------------------------------------- /src/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { DocumentDefinition, FilterQuery, UpdateQuery } from "mongoose"; 2 | import UserModel, { User } from "../models/user.model"; 3 | 4 | /** 5 | * Find user by Id 6 | * @param {string} id this is the id of the user 7 | * 8 | * @author aayushchugh 9 | */ 10 | export function findUserByIdService(id: string) { 11 | return UserModel.findById(id); 12 | } 13 | 14 | /** 15 | * Find a user from the database with matching email 16 | * @param {string} email this is email of the user 17 | * 18 | * @author aayushchugh 19 | */ 20 | export function findUserByEmailService(email: string) { 21 | return UserModel.findOne({ email }); 22 | } 23 | 24 | /** 25 | * Find user from database with given username 26 | * @param {string} username user's username to find in database 27 | * 28 | * @author aayushchugh 29 | */ 30 | export function findUserByUsernameService(username: string) { 31 | return UserModel.findOne({ username }); 32 | } 33 | 34 | /** 35 | * This will find a user from the database with matching username or email 36 | * @param {string} email email of the user 37 | * @param {string} username username of the user 38 | * 39 | * @author aayushchugh 40 | */ 41 | export function findUserByEitherEmailOrUsernameService(email: string, username: string) { 42 | return UserModel.findOne({ $or: [{ email }, { username }] }); 43 | } 44 | 45 | /** 46 | * This will create a new user in the database 47 | * @param {DocumentDefinition< 48 | Omit< 49 | User, 50 | | "uid" 51 | | "verified" 52 | | "verificationCode" 53 | | "passwordResetCode" 54 | | "comparePassword" 55 | > 56 | >} payload this is the payload of the user 57 | 58 | @author aayushchugh 59 | */ 60 | export function createUserService( 61 | payload: DocumentDefinition< 62 | Omit< 63 | User, 64 | | "uid" 65 | | "verification_code" 66 | | "password_reset_code" 67 | | "comparePassword" 68 | | "connected_services" 69 | > 70 | >, 71 | ) { 72 | return UserModel.create(payload); 73 | } 74 | 75 | /** 76 | * Find all users in database with given query 77 | * @param {object | undefined} query this is filter which will be used to find the user 78 | * 79 | * @author aayushchugh 80 | */ 81 | export function findUsersService(query?: FilterQuery<User>) { 82 | return UserModel.find(query || {}); 83 | } 84 | 85 | /** 86 | * Delete a user form the database with given id 87 | * @param {string} id this is id of user to be deleted 88 | * 89 | * @author aayushchugh 90 | */ 91 | export function deleteUserByIdService(id: string) { 92 | return UserModel.findByIdAndDelete(id); 93 | } 94 | 95 | /** 96 | * Update user in the database with given id 97 | * @param id this is id of user which will be updated 98 | * @param payload fields which will be updated in the user 99 | * 100 | * @author aayushchugh 101 | */ 102 | export function updateUserByIdService(id: string, payload: UpdateQuery<User>) { 103 | return UserModel.findByIdAndUpdate(id, payload); 104 | } 105 | 106 | /** 107 | * @author is-it-ayush 108 | * @description Babel and CommonJS fix: Without This *.test.js wont recognise the typescript functions. 109 | * 110 | * @author aayushchugh 111 | */ 112 | module.exports = { 113 | findUserByIdService, 114 | findUserByEmailService, 115 | findUserByUsernameService, 116 | findUserByEitherEmailOrUsernameService, 117 | createUserService, 118 | findUsersService, 119 | deleteUserByIdService, 120 | updateUserByIdService, 121 | }; 122 | -------------------------------------------------------------------------------- /src/schemas/mail.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * This schema will validate `GET /mail/gmail/:email` route 5 | * 6 | * @author aayushchugh 7 | */ 8 | export const getEmailsFromGmailSchema = z.object({ 9 | params: z.object({ 10 | email: z.string({ required_error: "Email is required" }), 11 | }), 12 | query: z.object({ 13 | maxResults: z.string().optional(), 14 | pageToken: z.string().optional(), 15 | q: z.string().optional(), 16 | includeSpamTrash: z.string().optional(), 17 | }), 18 | }); 19 | 20 | /** 21 | * This type is generated using `getEmailsFromGmailSchema` 22 | * 23 | * @author aayushchugh 24 | */ 25 | export type GetEmailsFromGmailSchema = z.TypeOf<typeof getEmailsFromGmailSchema>; 26 | 27 | /** 28 | * This schema will validate `POST /mail/gmail/:email` route 29 | * 30 | * @author aayushchugh 31 | */ 32 | export const postSendGmailSchema = z.object({ 33 | body: z.object({ 34 | to: z.string({ required_error: "to is required" }).email("to must be a valid email"), 35 | subject: z.string({ required_error: "subject is required" }), 36 | html: z.string({ required_error: "html is required" }), 37 | }), 38 | params: z.object({ 39 | email: z 40 | .string({ required_error: "email param is required" }) 41 | .email("please enter a valid email param"), 42 | }), 43 | }); 44 | 45 | /** 46 | * This type is generated from `sendGmailSchema` 47 | * 48 | * @author aayushchugh 49 | */ 50 | export type PostSendGmailSchema = z.TypeOf<typeof postSendGmailSchema>; 51 | 52 | /** 53 | * This schema will validate `/mail/:id/gmail/:email/:messageId` route 54 | * 55 | * @author tharun634 56 | */ 57 | export const getEmailFromGmailSchema = z.object({ 58 | params: z.object({ 59 | email: z 60 | .string({ required_error: "Email is required" }) 61 | .email("Please enter a valid email"), 62 | messageId: z.string({ required_error: "messageId is required" }), 63 | }), 64 | }); 65 | 66 | /** 67 | * This type is generated using `getEmailFromGmailSchema` 68 | * 69 | * @author tharun634 70 | */ 71 | export type GetEmailFromGmailSchema = z.TypeOf<typeof getEmailFromGmailSchema>; 72 | 73 | /** 74 | * This schema will validate `/mail/:id/gmail/:email/drafts` route 75 | * 76 | * @author tharun634 77 | */ 78 | export const getDraftsFromGmailSchema = z.object({ 79 | params: z.object({ 80 | email: z.string({ required_error: "Email is required" }), 81 | }), 82 | query: z.object({ 83 | maxResults: z.string().optional(), 84 | pageToken: z.string().optional(), 85 | q: z.string().optional(), 86 | includeSpamTrash: z.string().optional(), 87 | }), 88 | }); 89 | 90 | /** 91 | * This type is generated using `getDraftsFromGmailSchema` 92 | * 93 | * @author tharun634 94 | */ 95 | export type GetDraftsFromGmailSchema = z.TypeOf<typeof getDraftsFromGmailSchema>; 96 | 97 | /** 98 | * This schema will validate `DELETE /mail/:id/gmail/:email/:messageId` route 99 | * 100 | * @author aayushchugh 101 | */ 102 | export const deleteEmailFromGmailSchema = z.object({ 103 | params: z.object({ 104 | email: z 105 | .string({ required_error: "Email is required" }) 106 | .email("Please enter a valid email"), 107 | messageId: z.string({ required_error: "messageId is required" }), 108 | }), 109 | }); 110 | 111 | /** 112 | * This type is generated using `deleteEmailFromGmailSchema` 113 | * 114 | * @author aayushchugh 115 | */ 116 | export type DeleteEmailFromGmailSchema = z.TypeOf<typeof deleteEmailFromGmailSchema>; 117 | -------------------------------------------------------------------------------- /src/routes/auth.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | forgotPasswordHandler, 4 | getCurrentUserHandler, 5 | googleOauthHandler, 6 | loginHandler, 7 | logoutHandler, 8 | redirectToGoogleConsentScreenHandler, 9 | refreshAccessTokenHandler, 10 | resetPasswordHandler, 11 | signupHandler, 12 | verifyUserHandler, 13 | } from "../controllers/auth.controller"; 14 | import deserializeUser from "../middleware/deserializeUser.middleware"; 15 | import validateRequest from "../middleware/validateRequest.middleware"; 16 | import { 17 | forgotPasswordSchema, 18 | loginSchema, 19 | redirectToGoogleConsentScreenHandlerSchema, 20 | resetPasswordSchema, 21 | signupSchema, 22 | verifyUserSchema, 23 | } from "../schemas/auth.schema"; 24 | 25 | const authRouter: Router = Router(); 26 | 27 | // NOTE: all routes defined with `authRouter` will be pre-fixed with `/api` 28 | 29 | /** 30 | * Use can signup using this route 31 | * after he signup an email containing OTP will be sent to his email 32 | * 33 | * @author aayushchugh 34 | */ 35 | authRouter.post("/auth/signup", validateRequest(signupSchema), signupHandler); 36 | 37 | /** 38 | * This route is used for verifying the user's email after 39 | * he sign's up 40 | * 41 | * `verificationCode` --> OTP sent on users email 42 | * 43 | * @author aayushchugh 44 | */ 45 | authRouter.get( 46 | "/auth/verify/:verificationCode", 47 | validateRequest(verifyUserSchema), 48 | deserializeUser, 49 | verifyUserHandler, 50 | ); 51 | 52 | /** 53 | * User can login with this route. 54 | * a `access_token` and `refresh_token` will be sent 55 | * on login 56 | * 57 | * @author aayushchugh 58 | */ 59 | authRouter.post("/auth/login", validateRequest(loginSchema), loginHandler); 60 | 61 | /** 62 | * this route will logout user 63 | * 64 | * @author aayushchugh 65 | */ 66 | authRouter.get("/auth/logout", logoutHandler); 67 | 68 | /** 69 | * This route will get current user by using `access_token` 70 | * 71 | * @author aayushchugh 72 | */ 73 | authRouter.get("/auth/me", deserializeUser, getCurrentUserHandler); 74 | 75 | /** 76 | * This route will send a passwordResetCode to provided email 77 | * and that code will be verified in /auth/resetpassword/:email/:passwordResetCode 78 | * route 79 | * 80 | * @author aayushchugh 81 | */ 82 | authRouter.post( 83 | "/auth/forgotpassword", 84 | validateRequest(forgotPasswordSchema), 85 | forgotPasswordHandler, 86 | ); 87 | 88 | /** 89 | * This route will verify the code sent by forgotPasswordRoute 90 | * and will change the password in database 91 | * 92 | * @author aayushchugh 93 | */ 94 | authRouter.patch( 95 | "/auth/resetpassword/:email/:passwordResetCode", 96 | validateRequest(resetPasswordSchema), 97 | resetPasswordHandler, 98 | ); 99 | 100 | /** 101 | * This route is used to refresh the `access_token` using 102 | * `refresh_token` 103 | * 104 | * @author aayushchugh 105 | */ 106 | authRouter.get("/auth/refresh", refreshAccessTokenHandler); 107 | 108 | /** 109 | * This route will redirect user to google consent screen 110 | * 111 | * @author NullableDev, aayushchugh 112 | */ 113 | authRouter.get( 114 | "/auth/oauth/google", 115 | validateRequest(redirectToGoogleConsentScreenHandlerSchema), 116 | redirectToGoogleConsentScreenHandler, 117 | ); 118 | 119 | /** 120 | * This route will redirect to /fail and /success route 121 | * from Google consent screen 122 | * 123 | * @author NullableDev, aayushchugh 124 | */ 125 | authRouter.get("/auth/oauth/google/redirect", googleOauthHandler); 126 | 127 | export default authRouter; 128 | -------------------------------------------------------------------------------- /src/controllers/ticket.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { 4 | createTicketService, 5 | deleteTicketByIdService, 6 | findTicketsService, 7 | updateTicketByIdService, 8 | } from "../services/ticket.service"; 9 | import { 10 | CreateTicketSchema, 11 | DeleteTicketSchema, 12 | PatchTicketStatusSchema, 13 | } from "../schemas/ticket.schema"; 14 | 15 | /** 16 | * This controller will create new support ticket in database. 17 | * user can only submit 3 tickets. 18 | * they will have to wait for previous tickets to resolve 19 | * before submitting new one 20 | * 21 | * @param req request 22 | * @param res response 23 | * 24 | * @author aayushchugh 25 | */ 26 | export const createTicketHandler = async ( 27 | req: Request<{}, {}, CreateTicketSchema["body"]>, 28 | res: Response, 29 | ) => { 30 | const { message, name, email, subject } = req.body; 31 | 32 | try { 33 | // if user have already submitted tickets 3 times than don't let me submit more 34 | 35 | const existingTickets = await findTicketsService({ email, status: "new" }); 36 | 37 | if (existingTickets.length >= 3) { 38 | return res.status(StatusCodes.CONFLICT).json({ 39 | error: "You have already submitted 3 tickets, Please wait for our response", 40 | }); 41 | } 42 | 43 | await createTicketService({ message, name, email, subject, status: "new" }); 44 | 45 | res.status(StatusCodes.CREATED).json({ 46 | message: "Successfully created support ticket, You will be contacted shortly", 47 | }); 48 | } catch (err) { 49 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 50 | error: "Internal server error", 51 | }); 52 | } 53 | }; 54 | 55 | /** 56 | * This controller will fetch all tickets from database 57 | * 58 | * @param req request 59 | * @param res response 60 | * 61 | * @author aayushchugh 62 | */ 63 | export const getAllTicketsHandler = async (req: Request, res: Response) => { 64 | try { 65 | const records = await findTicketsService(); 66 | 67 | return res.status(StatusCodes.OK).json({ 68 | message: "Successfully fetched all support tickets", 69 | records, 70 | }); 71 | } catch (err) { 72 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 73 | error: "Internal server error", 74 | }); 75 | } 76 | }; 77 | 78 | /** 79 | * This controller will update the status of the ticket 80 | * 81 | * @param req request 82 | * @param res response 83 | * 84 | * @author aayushchugh 85 | */ 86 | export const patchTicketStatusHandler = async ( 87 | req: Request<PatchTicketStatusSchema["params"], {}, PatchTicketStatusSchema["body"]>, 88 | res: Response, 89 | ) => { 90 | const { status } = req.body; 91 | const { id } = req.params; 92 | 93 | try { 94 | const updatedTicket = await updateTicketByIdService(id, { status }); 95 | 96 | if (!updatedTicket) { 97 | return res.status(StatusCodes.NOT_FOUND).json({ 98 | error: "Ticket not found", 99 | }); 100 | } 101 | 102 | return res.status(StatusCodes.OK).json({ 103 | message: "Successfully updated support ticket status", 104 | }); 105 | } catch (err) { 106 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 107 | error: "Internal server error", 108 | }); 109 | } 110 | }; 111 | 112 | /** 113 | * This controller will delete ticket with id 114 | * 115 | * @param req request 116 | * @param res response 117 | * 118 | * @author aayushchugh 119 | */ 120 | export const deleteTicketHandler = async ( 121 | req: Request<DeleteTicketSchema["params"]>, 122 | res: Response, 123 | ) => { 124 | const { id } = req.params; 125 | 126 | try { 127 | const deletedTicket = await deleteTicketByIdService(id); 128 | 129 | if (!deletedTicket) { 130 | return res.status(StatusCodes.NOT_FOUND).json({ error: "Ticket not found" }); 131 | } 132 | 133 | return res.status(StatusCodes.OK).json({ 134 | message: "Successfully deleted support ticket", 135 | }); 136 | } catch (err) { 137 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 138 | error: "Internal server error", 139 | }); 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /src/controllers/marketingEmail.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { CreateMarketingEmailSchema } from "../schemas/marketingEmail.schema"; 4 | import { 5 | createMarketingEmailService, 6 | findMarketingEmailsService, 7 | } from "../services/marketingEmail.service"; 8 | import { findUserByIdService, findUsersService } from "../services/user.service"; 9 | import { sendEmail } from "../utils/email.util"; 10 | 11 | /** 12 | * This controller will send marketing emails 13 | * 14 | * @param req express request 15 | * @param res express response 16 | * 17 | * @author tharun634, aayushchugh 18 | */ 19 | export const createMarketingEmailHandler = async ( 20 | req: Request<{}, {}, CreateMarketingEmailSchema["body"]>, 21 | res: Response, 22 | ) => { 23 | try { 24 | const { subject, specificUsers, allUsers, html } = req.body; 25 | 26 | // validate request 27 | if (!specificUsers && !allUsers) { 28 | return res.status(StatusCodes.BAD_REQUEST).json({ 29 | error: "You must select either specificUsers or allUsers", 30 | }); 31 | } 32 | 33 | if (specificUsers && specificUsers.length > 0 && allUsers) { 34 | return res.status(StatusCodes.BAD_REQUEST).json({ 35 | error: "You can only select one, specificUsers or allUsers", 36 | }); 37 | } 38 | 39 | /** 40 | * this helper function will generate url to unsubscribe user from marketing emails 41 | * @param userId id of the user 42 | * 43 | * @author aayushchugh 44 | */ 45 | const unsubscribeUserURLGenerator = (userId: string) => { 46 | return `<a href="${process.env.BASE_URL}/api/users/${userId}/marketing-emails/unsubscribe">Unsubscribe</a>`; 47 | }; 48 | 49 | /** 50 | * Send email to all the users those have selected the checkbox 51 | */ 52 | if (allUsers) { 53 | const users = await findUsersService({ receive_marketing_emails: true }); 54 | 55 | if (!users) { 56 | return res.status(StatusCodes.NOT_FOUND).json({ 57 | error: "No user has opted to receive marketing emails", 58 | }); 59 | } 60 | 61 | users.forEach( 62 | async (user) => 63 | await sendEmail( 64 | user.email, 65 | subject, 66 | `${html}${unsubscribeUserURLGenerator(user._id)}`, 67 | ), 68 | ); 69 | 70 | await createMarketingEmailService({ 71 | subject, 72 | users: users.map((user) => user._id), 73 | }); 74 | 75 | return res.status(StatusCodes.OK).json({ 76 | message: "Email sent successfully", 77 | }); 78 | } 79 | 80 | /** 81 | * Send email to specific users 82 | */ 83 | if (specificUsers && specificUsers.length > 0) { 84 | // get specific users from DB 85 | const users = await Promise.all( 86 | specificUsers.map((userId) => findUserByIdService(userId)), 87 | ); 88 | 89 | users.forEach( 90 | async (user) => 91 | user && 92 | user.receive_marketing_emails && 93 | (await sendEmail( 94 | user.email, 95 | subject, 96 | `${html}${unsubscribeUserURLGenerator(user._id)}`, 97 | )), 98 | ); 99 | 100 | await createMarketingEmailService({ 101 | subject, 102 | users: users.map((user) => user && user.receive_marketing_emails && user._id), 103 | }); 104 | 105 | return res.status(StatusCodes.OK).json({ 106 | message: "Email sent successfully", 107 | }); 108 | } 109 | } catch (err) { 110 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 111 | error: "Internal server error", 112 | }); 113 | } 114 | }; 115 | 116 | /** 117 | * This controller will fetch all marketing emails from database 118 | * 119 | * @param req request 120 | * @param res response 121 | * 122 | * @author tharun634 123 | */ 124 | export const getAllMarketingEmailsHandler = async (req: Request, res: Response) => { 125 | try { 126 | const records = await findMarketingEmailsService(); 127 | 128 | return res.status(StatusCodes.OK).json({ 129 | message: "Successfully fetched all marketing emails", 130 | records, 131 | }); 132 | } catch (err) { 133 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 134 | error: "Internal server error", 135 | }); 136 | } 137 | }; 138 | -------------------------------------------------------------------------------- /src/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * This schema is used to validate `DELETE /users/:id` request 5 | * 6 | * @author aayushchugh, is-it-ayush 7 | */ 8 | export const deleteUserSchema = z.object({ 9 | params: z.object({ 10 | id: z.string({ required_error: "Id is required" }), 11 | }), 12 | }); 13 | 14 | /** 15 | * This type is generated using `deleteUserSchema` and can be used 16 | * as express Request type generic 17 | * 18 | * @author aayushchugh, is-it-ayush 19 | */ 20 | export type DeleteUserSchema = z.infer<typeof deleteUserSchema>; 21 | 22 | /** 23 | * This schema is used to validate `/users/markverified/:id` request 24 | * 25 | * @author aayushchugh, is-it-ayush 26 | */ 27 | export const patchMarkUserVerifiedSchema = z.object({ 28 | params: z.object({ 29 | id: z.string({ required_error: "Id is required" }), 30 | }), 31 | }); 32 | 33 | /** 34 | * This type is generated using `patchMarkUserVerifiedSchema` and can be used 35 | * as express Request type generic 36 | * 37 | * @author aayushchugh, is-it-ayush 38 | */ 39 | export type PatchMarkUserVerifiedSchema = z.infer<typeof patchMarkUserVerifiedSchema>; 40 | 41 | /** 42 | * This schema is used to validate `/admin/users/markadmin/:id` request 43 | * 44 | * @author tharun634 45 | */ 46 | export const patchMarkUserAdminSchema = z.object({ 47 | params: z.object({ 48 | id: z.string({ required_error: "Id is required" }), 49 | }), 50 | }); 51 | 52 | // eslint-disable-next-line no-secrets/no-secrets 53 | /** 54 | * This type is generated using `patchMarkUserAdminSchema` and can be used 55 | * as express Request type generic 56 | * 57 | * @author tharun634 58 | */ 59 | export type PatchMarkUserAdminSchema = z.infer<typeof patchMarkUserAdminSchema>; 60 | 61 | /** 62 | * This schema is used to validate `GET /admin/users/:id` request 63 | * 64 | * @author tharun634, aayushchugh 65 | */ 66 | export const getAllUsersSchema = z.object({ 67 | query: z.object({ 68 | page: z.string().min(1, "page must be greater than 0").optional(), 69 | size: z.string().min(1, "limit must be greater than 0").optional(), 70 | receiveMarketingEmails: z.boolean().optional(), 71 | }), 72 | }); 73 | 74 | /** 75 | * This schema is used to validate `GET /admin/users/:id` request 76 | * 77 | * @author aayushchugh 78 | */ 79 | export const getSingleUserSchema = z.object({ 80 | params: z.object({ 81 | id: z.string({ required_error: "Id is required" }), 82 | }), 83 | }); 84 | 85 | /** 86 | * This type is generated using `getSingleUserSchema` and can be used 87 | * as express Request type generic 88 | * 89 | * @author aayushchugh 90 | */ 91 | export type GetSingleUserSchema = z.infer<typeof getSingleUserSchema>; 92 | 93 | /** 94 | * This type is generated using `getAllUsersHandler` and can be used 95 | * as express Request type generic 96 | * 97 | * @author tharun634 98 | */ 99 | export type GetAllUsersSchema = z.TypeOf<typeof getAllUsersSchema>; 100 | 101 | /** 102 | * This schema is used to validate `PATCH /users/:id` request 103 | * 104 | * @author aayushchugh 105 | */ 106 | export const patchUserSchema = z.object({ 107 | params: z.object({ 108 | id: z.string({ required_error: "Id is required" }), 109 | }), 110 | body: z.object({ 111 | username: z.string().optional(), 112 | }), 113 | }); 114 | 115 | /** 116 | * This type is generated using `patchUserSchema` and can be used 117 | * as express Request type generic 118 | * 119 | * @author aayushchugh 120 | */ 121 | export type PatchUserSchema = z.infer<typeof patchUserSchema>; 122 | 123 | /** 124 | * This schema is used to validate `GET /users/:id/marketing-emails/unsubscribe` request 125 | * 126 | * @author aayushchugh 127 | */ 128 | export const getUnsubscribeUserFromMarketingEmailSchema = z.object({ 129 | params: z.object({ 130 | id: z.string({ required_error: "id is required" }), 131 | }), 132 | }); 133 | 134 | // eslint-disable-next-line no-secrets/no-secrets 135 | /** 136 | * This type is generated using `unsubscribeUserFromMarketingEmailSchema` 137 | * 138 | * @author aayushchugh 139 | */ 140 | export type GetUnsubscribeUserFromMarketingEmailSchema = z.TypeOf< 141 | typeof getUnsubscribeUserFromMarketingEmailSchema 142 | >; 143 | -------------------------------------------------------------------------------- /src/schemas/auth.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * This is schema to validate /auth/signup request 5 | * 6 | * @constant 7 | * @author aayushchugh 8 | */ 9 | export const signupSchema = z.object({ 10 | body: z 11 | .object({ 12 | username: z 13 | .string({ required_error: "Please enter your username" }) 14 | .min(3, "Name must be at least 3 characters") 15 | .max(50, "Name can't be longer than 50 characters") 16 | .regex(/^[a-z\d0-9]+$/), 17 | email: z 18 | .string({ required_error: "Email is required" }) 19 | .email("Please enter a valid email"), 20 | receiveMarketingEmails: z.boolean({ 21 | required_error: "receive marketing emails can be either true of false", 22 | }), 23 | acceptedTermsAndConditions: z 24 | .boolean({ 25 | required_error: "Please agree to terms and conditions", 26 | }) 27 | .refine((value) => value === true, { 28 | path: ["custom"], 29 | message: "Please agree to terms and conditions", 30 | }), 31 | password: z 32 | .string({ required_error: "Password is required" }) 33 | .min(6, "Password must be at least 6 characters"), 34 | cpassword: z 35 | .string({ required_error: "Confirm password is required" }) 36 | .min(6, "Password must be at least 6 characters"), 37 | }) 38 | .refine((data) => data.password === data.cpassword, { 39 | path: ["custom"], 40 | message: "Password and Confirm password do not match", 41 | }), 42 | }); 43 | 44 | /** 45 | * This type is generated using signupSchema and can be used 46 | * as express Request type generic 47 | * 48 | * @author aayushchugh 49 | */ 50 | export type SignupSchema = z.TypeOf<typeof signupSchema>; 51 | 52 | /** 53 | * This is schema to validate /auth/verify/:email/:verificationCode request 54 | * 55 | * @constant 56 | * @author aayushchugh 57 | */ 58 | export const verifyUserSchema = z.object({ 59 | params: z.object({ 60 | verificationCode: z.string({ 61 | required_error: "Verification code is required", 62 | }), 63 | }), 64 | }); 65 | 66 | /** 67 | * This type is generated using verifyUserSchema and can be used 68 | * as express Request type generic 69 | * 70 | * @author aayushchugh 71 | */ 72 | export type VerifyUserSchema = z.TypeOf<typeof verifyUserSchema>; 73 | 74 | /** 75 | * This is schema to validate /auth/login request 76 | * 77 | * @constant 78 | * @author aayushchugh 79 | */ 80 | export const loginSchema = z.object({ 81 | body: z.object({ 82 | email: z.string().email("Please enter a valid email").optional(), 83 | username: z.string().optional(), 84 | password: z 85 | .string({ required_error: "Password is required" }) 86 | .min(6, "Invalid credentials"), 87 | }), 88 | }); 89 | 90 | /** 91 | * This type is generated using loginSchema and can be used 92 | * as express Request type generic 93 | * 94 | * @author aayushchugh 95 | */ 96 | export type LoginSchema = z.TypeOf<typeof loginSchema>; 97 | 98 | /** 99 | * This is schema to validate /auth/login request 100 | * 101 | * @constant 102 | * @author aayushchugh 103 | */ 104 | export const forgotPasswordSchema = z.object({ 105 | body: z.object({ 106 | email: z 107 | .string({ required_error: "Email is required" }) 108 | .email("Please enter a valid email"), 109 | }), 110 | }); 111 | 112 | /** 113 | * This type is generated using forgotPasswordSchema and can be used 114 | * as express Request type generic 115 | * 116 | * @author aayushchugh 117 | */ 118 | export type ForgotPasswordSchema = z.TypeOf<typeof forgotPasswordSchema>; 119 | 120 | /** 121 | * This is schema to validate /auth/resetpassword/:email/:passwordResetCode request 122 | * 123 | * @constant 124 | * @author aayushchugh 125 | */ 126 | export const resetPasswordSchema = z.object({ 127 | params: z.object({ 128 | email: z 129 | .string({ required_error: "Email is required" }) 130 | .email("Please provide a valid email"), 131 | passwordResetCode: z 132 | .string({ 133 | required_error: "passwordResetCode is required", 134 | }) 135 | .min(4, "passwordResetCode must be 4 characters long") 136 | .max(4, "passwordResetCode must be 4 characters long"), 137 | }), 138 | body: z 139 | .object({ 140 | password: z 141 | .string({ required_error: "password is required" }) 142 | .min(6, "password should be longer than 4 characters"), 143 | 144 | cpassword: z 145 | .string({ required_error: "cpassword is required" }) 146 | .min(6, "password should be longer than 4 characters"), 147 | }) 148 | .refine((data) => data.password === data.cpassword, { 149 | message: "Password and confirm password do not match", 150 | path: ["cpassword"], 151 | }), 152 | }); 153 | 154 | /** 155 | * This type is generated using resetPasswordSchema and can be used 156 | * as express Request type generic 157 | * 158 | * @author aayushchugh 159 | */ 160 | export type ResetPasswordSchema = z.TypeOf<typeof resetPasswordSchema>; 161 | 162 | /** 163 | * This schema is to validate /auth/oauth/google route 164 | * 165 | * @constant 166 | * @author aayushchugh 167 | */ 168 | export const redirectToGoogleConsentScreenHandlerSchema = z.object({ 169 | query: z.object({ 170 | id: z.string({ required_error: "Id is required" }), 171 | }), 172 | }); 173 | 174 | export type RedirectToGoogleConsentScreenHandlerSchema = z.TypeOf< 175 | typeof redirectToGoogleConsentScreenHandlerSchema 176 | >; 177 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | mutiiemail@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "backend", 3 | "projectOwner": "MultiEmail", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "aayushchugh", 15 | "name": "Ayush Chugh", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/69336518?v=4", 17 | "profile": "https://shriproperty.com", 18 | "contributions": [ 19 | "bug", 20 | "ideas", 21 | "mentoring", 22 | "security", 23 | "code", 24 | "maintenance", 25 | "projectManagement", 26 | "review" 27 | ] 28 | }, 29 | { 30 | "login": "DaatUserName", 31 | "name": "Toby", 32 | "avatar_url": "https://avatars.githubusercontent.com/u/40370496?v=4", 33 | "profile": "https://github.com/DaatUserName", 34 | "contributions": [ 35 | "code", 36 | "review", 37 | "maintenance" 38 | ] 39 | }, 40 | { 41 | "login": "shivamvishwakarm", 42 | "name": "shivam vishwakarma", 43 | "avatar_url": "https://avatars.githubusercontent.com/u/80755217?v=4", 44 | "profile": "https://github.com/shivamvishwakarm", 45 | "contributions": [ 46 | "doc", 47 | "code" 48 | ] 49 | }, 50 | { 51 | "login": "tharun634", 52 | "name": "Tharun K", 53 | "avatar_url": "https://avatars.githubusercontent.com/u/53267275?v=4", 54 | "profile": "https://github.com/tharun634", 55 | "contributions": [ 56 | "doc", 57 | "code", 58 | "infra" 59 | ] 60 | }, 61 | { 62 | "login": "is-it-ayush", 63 | "name": "Ayush", 64 | "avatar_url": "https://avatars.githubusercontent.com/u/36449128?v=4", 65 | "profile": "https://github.com/is-it-ayush", 66 | "contributions": [ 67 | "bug", 68 | "security", 69 | "ideas", 70 | "mentoring", 71 | "code", 72 | "maintenance", 73 | "projectManagement", 74 | "review" 75 | ] 76 | }, 77 | { 78 | "login": "CodesWithJames", 79 | "name": "James", 80 | "avatar_url": "https://avatars.githubusercontent.com/u/71551059?v=4", 81 | "profile": "https://www.jamesmesser.xyz", 82 | "contributions": [ 83 | "financial", 84 | "ideas", 85 | "business" 86 | ] 87 | }, 88 | { 89 | "login": "AndrewFirePvP7", 90 | "name": "AndrewDev", 91 | "avatar_url": "https://avatars.githubusercontent.com/u/29314485?v=4", 92 | "profile": "https://github.com/AndrewFirePvP7", 93 | "contributions": [ 94 | "ideas" 95 | ] 96 | }, 97 | { 98 | "login": "Arpitchugh", 99 | "name": "Arpit Chugh", 100 | "avatar_url": "https://avatars.githubusercontent.com/u/63435960?v=4", 101 | "profile": "https://arpitchugh.live/", 102 | "contributions": [ 103 | "doc" 104 | ] 105 | }, 106 | { 107 | "login": "drishit96", 108 | "name": "Drishit Mitra", 109 | "avatar_url": "https://avatars.githubusercontent.com/u/13049630?v=4", 110 | "profile": "https://github.com/drishit96", 111 | "contributions": [ 112 | "code" 113 | ] 114 | }, 115 | { 116 | "login": "Areadrill", 117 | "name": "João Mota", 118 | "avatar_url": "https://avatars.githubusercontent.com/u/9729792?v=4", 119 | "profile": "https://github.com/Areadrill", 120 | "contributions": [ 121 | "code" 122 | ] 123 | }, 124 | { 125 | "login": "shashankbhatgs", 126 | "name": "Shashank Bhat G S", 127 | "avatar_url": "https://avatars.githubusercontent.com/u/76593166?v=4", 128 | "profile": "https://github.com/shashankbhatgs", 129 | "contributions": [ 130 | "doc" 131 | ] 132 | }, 133 | { 134 | "login": "alberturria", 135 | "name": "Alberto Herrera Vargas", 136 | "avatar_url": "https://avatars.githubusercontent.com/u/32776999?v=4", 137 | "profile": "https://github.com/alberturria", 138 | "contributions": [ 139 | "code" 140 | ] 141 | }, 142 | { 143 | "login": "VadimDez", 144 | "name": "Vadym Yatsyuk", 145 | "avatar_url": "https://avatars.githubusercontent.com/u/3748453?v=4", 146 | "profile": "https://vadimdez.github.io", 147 | "contributions": [ 148 | "code" 149 | ] 150 | }, 151 | { 152 | "login": "NullableDev", 153 | "name": "Toby", 154 | "avatar_url": "https://avatars.githubusercontent.com/u/40370496?v=4", 155 | "profile": "https://github.com/NullableDev", 156 | "contributions": [ 157 | "bug", 158 | "security", 159 | "ideas" 160 | ] 161 | }, 162 | { 163 | "login": "YashJain2409", 164 | "name": "yash jain", 165 | "avatar_url": "https://avatars.githubusercontent.com/u/60182679?v=4", 166 | "profile": "https://yash-jain-portfolio.netlify.app/", 167 | "contributions": [ 168 | "code" 169 | ] 170 | }, 171 | { 172 | "login": "bonganibg", 173 | "name": "Bongani Gumbo", 174 | "avatar_url": "https://avatars.githubusercontent.com/u/88882571?v=4", 175 | "profile": "http://bonganibg.github.io", 176 | "contributions": [ 177 | "doc" 178 | ] 179 | }, 180 | { 181 | "login": "shikhar13012001", 182 | "name": "Shikhar", 183 | "avatar_url": "https://avatars.githubusercontent.com/u/75368010?v=4", 184 | "profile": "https://portfolio-shikhar13012001.vercel.app/", 185 | "contributions": [ 186 | "code" 187 | ] 188 | } 189 | ], 190 | "contributorsPerLine": 7, 191 | "linkToUsage": true 192 | } 193 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // Automatically clear mock calls, instances, contexts and results before every test 14 | clearMocks: true, 15 | 16 | // Indicates whether the coverage information should be collected while executing the test 17 | collectCoverage: true, 18 | 19 | // An array of glob patterns indicating a set of files for which coverage information should be collected 20 | // collectCoverageFrom: undefined, 21 | 22 | // The directory where Jest should output its coverage files 23 | coverageDirectory: "coverage", 24 | 25 | // An array of regexp pattern strings used to skip coverage collection 26 | // coveragePathIgnorePatterns: [ 27 | // "\\\\node_modules\\\\" 28 | // ], 29 | 30 | // Indicates which provider should be used to instrument code for coverage 31 | // coverageProvider: "babel", 32 | 33 | // A list of reporter names that Jest uses when writing coverage reports 34 | // coverageReporters: [ 35 | // "json", 36 | // "text", 37 | // "lcov", 38 | // "clover" 39 | // ], 40 | 41 | // An object that configures minimum threshold enforcement for coverage results 42 | // coverageThreshold: undefined, 43 | 44 | // A path to a custom dependency extractor 45 | // dependencyExtractor: undefined, 46 | 47 | // Make calling deprecated APIs throw helpful error messages 48 | // errorOnDeprecated: false, 49 | 50 | // The default configuration for fake timers 51 | // fakeTimers: { 52 | // "enableGlobally": false 53 | // }, 54 | 55 | // Force coverage collection from ignored files using an array of glob patterns 56 | // forceCoverageMatch: [], 57 | 58 | // A path to a module which exports an async function that is triggered once before all test suites 59 | // globalSetup: undefined, 60 | 61 | // A path to a module which exports an async function that is triggered once after all test suites 62 | // globalTeardown: undefined, 63 | 64 | // A set of global variables that need to be available in all test environments 65 | // globals: {}, 66 | 67 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 68 | // maxWorkers: "50%", 69 | 70 | // An array of directory names to be searched recursively up from the requiring module's location 71 | // moduleDirectories: [ 72 | // "node_modules" 73 | // ], 74 | 75 | // An array of file extensions your modules use 76 | moduleFileExtensions: [ 77 | "js", 78 | "mjs", 79 | "cjs", 80 | "jsx", 81 | "ts", 82 | "tsx", 83 | "json", 84 | "node" 85 | ], 86 | 87 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 88 | // moduleNameMapper: {}, 89 | 90 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 91 | // modulePathIgnorePatterns: [], 92 | 93 | // Activates notifications for test results 94 | // notify: false, 95 | 96 | // An enum that specifies notification mode. Requires { notify: true } 97 | // notifyMode: "failure-change", 98 | 99 | // A preset that is used as a base for Jest's configuration 100 | // preset: undefined, 101 | 102 | // Run tests from one or more projects 103 | // projects: undefined, 104 | 105 | // Use this configuration option to add custom reporters to Jest 106 | // reporters: undefined, 107 | 108 | // Automatically reset mock state before every test 109 | // resetMocks: false, 110 | 111 | // Reset the module registry before running each individual test 112 | // resetModules: false, 113 | 114 | // A path to a custom resolver 115 | // resolver: undefined, 116 | 117 | // Automatically restore mock state and implementation before every test 118 | // restoreMocks: false, 119 | 120 | // The root directory that Jest should scan for tests and modules within 121 | // rootDir: undefined, 122 | 123 | // A list of paths to directories that Jest should use to search for files in 124 | // roots: [ 125 | // "<rootDir>" 126 | // ], 127 | 128 | // Allows you to use a custom runner instead of Jest's default test runner 129 | // runner: "jest-runner", 130 | 131 | // The paths to modules that run some code to configure or set up the testing environment before each test 132 | // setupFiles: [], 133 | 134 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 135 | // setupFilesAfterEnv: [], 136 | 137 | // The number of seconds after which a test is considered as slow and reported as such in the results. 138 | // slowTestThreshold: 5, 139 | 140 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 141 | // snapshotSerializers: [], 142 | 143 | // The test environment that will be used for testing 144 | // testEnvironment: "jest-environment-node", 145 | 146 | // Options that will be passed to the testEnvironment 147 | // testEnvironmentOptions: {}, 148 | 149 | // Adds a location field to test results 150 | // testLocationInResults: false, 151 | 152 | // The glob patterns Jest uses to detect test files 153 | // testMatch: [ 154 | // "**/__tests__/**/*.[jt]s?(x)", 155 | // "**/?(*.)+(spec|test).[tj]s?(x)" 156 | // ], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | // testPathIgnorePatterns: [ 160 | // "\\\\node_modules\\\\" 161 | // ], 162 | 163 | // The regexp pattern or array of patterns that Jest uses to detect test files 164 | // testRegex: [], 165 | 166 | // This option allows the use of a custom results processor 167 | // testResultsProcessor: undefined, 168 | 169 | // This option allows use of a custom test runner 170 | // testRunner: "jest-circus/runner", 171 | 172 | transform: { 173 | "^.+\\.jsx?$": "babel-jest", 174 | "^.+\\.tsx?$": "ts-jest" 175 | }, 176 | 177 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 178 | // transformIgnorePatterns: [ 179 | // "\\\\node_modules\\\\", 180 | // "\\.pnp\\.[^\\\\]+$" 181 | // ], 182 | 183 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 184 | // unmockedModulePathPatterns: undefined, 185 | 186 | // Indicates whether each individual test should be reported during the run 187 | // verbose: undefined, 188 | 189 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 190 | // watchPathIgnorePatterns: [], 191 | 192 | // Whether to use watchman for file crawling 193 | // watchman: true, 194 | }; 195 | -------------------------------------------------------------------------------- /src/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { 4 | DeleteUserSchema, 5 | GetAllUsersSchema, 6 | PatchMarkUserAdminSchema, 7 | PatchMarkUserVerifiedSchema, 8 | GetUnsubscribeUserFromMarketingEmailSchema, 9 | GetSingleUserSchema, 10 | } from "../schemas/user.schema"; 11 | 12 | import { PatchUserSchema } from "../schemas/user.schema"; 13 | import { 14 | deleteUserByIdService, 15 | findUserByIdService, 16 | findUserByUsernameService, 17 | findUsersService, 18 | updateUserByIdService, 19 | } from "../services/user.service"; 20 | import logger from "../utils/logger.util"; 21 | import { omit } from "lodash"; 22 | import { userModalPrivateFields } from "../models/user.model"; 23 | 24 | /** 25 | * This controller will get all users from database 26 | * 27 | * @param req request 28 | * @param res response 29 | * 30 | * @author aayushchugh, is-itayush, tharun634 31 | */ 32 | export const getAllUsersHandler = async ( 33 | req: Request<{}, {}, {}, GetAllUsersSchema["query"]>, 34 | res: Response, 35 | ) => { 36 | try { 37 | let { page, size, receiveMarketingEmails } = req.query; 38 | 39 | if (!page) page = "1"; 40 | if (!size) size = "10"; 41 | 42 | const limit = +size; 43 | const skip = (+page - 1) * limit; 44 | const query = receiveMarketingEmails ? { receive_marketing_emails: true } : {}; 45 | 46 | const records = await findUsersService(query).limit(limit).skip(skip); 47 | 48 | const removedPrivateFiledFromRecords = records.map((record) => 49 | omit(record.toJSON(), userModalPrivateFields), 50 | ); 51 | 52 | return res.status(StatusCodes.OK).json({ 53 | message: "Users fetched successfully", 54 | records: removedPrivateFiledFromRecords, 55 | page: +page, 56 | size: +size, 57 | }); 58 | } catch (err) { 59 | logger.error(err); 60 | 61 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 62 | error: "Internal Server Error", 63 | }); 64 | } 65 | }; 66 | 67 | /** 68 | * This controller will delete user from database 69 | * 70 | * @param req request 71 | * @param res response 72 | * 73 | * @author aayushchugh, is-it-ayush 74 | */ 75 | export const deleteUserHandler = async ( 76 | req: Request<DeleteUserSchema["params"]>, 77 | res: Response, 78 | ) => { 79 | const { id } = req.params; 80 | 81 | try { 82 | const deletedUser = await deleteUserByIdService(id); 83 | 84 | if (!deletedUser) { 85 | return res.status(StatusCodes.NOT_FOUND).json({ 86 | error: "User not found", 87 | }); 88 | } 89 | 90 | return res.status(StatusCodes.OK).json({ 91 | message: "User deleted successfully", 92 | }); 93 | } catch (err) { 94 | logger.error(err); 95 | 96 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 97 | error: "Internal Server Error", 98 | }); 99 | } 100 | }; 101 | 102 | /** 103 | * This controller will update user's username 104 | * 105 | * @param req request 106 | * @param res response 107 | * 108 | * @author aayushchugh 109 | */ 110 | 111 | export const patchUserHandler = async ( 112 | req: Request<PatchUserSchema["params"], {}, PatchUserSchema["body"]>, 113 | res: Response, 114 | ) => { 115 | const { id } = req.params; 116 | const { username } = req.body; 117 | 118 | try { 119 | // check if username is already taken 120 | 121 | const existingUserWithSameUsername = await findUserByUsernameService(username || ""); 122 | 123 | if (existingUserWithSameUsername) { 124 | return res.status(StatusCodes.CONFLICT).json({ 125 | error: "Username is already taken", 126 | }); 127 | } 128 | 129 | const updatedUser = await updateUserByIdService(id, { username }); 130 | 131 | if (!updatedUser) { 132 | return res.status(StatusCodes.NOT_FOUND).json({ 133 | error: "User not found", 134 | }); 135 | } 136 | 137 | return res.status(StatusCodes.OK).json({ 138 | message: "User updated successfully", 139 | }); 140 | } catch (err) { 141 | logger.error(err); 142 | 143 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 144 | error: "Internal Server Error", 145 | }); 146 | } 147 | }; 148 | 149 | /** 150 | * This controller will get single user from database with given id 151 | * 152 | * @param req express request 153 | * @param res express response 154 | * 155 | * @author aayushchugh 156 | */ 157 | export const getSingleUserHandler = async ( 158 | req: Request<GetSingleUserSchema["params"]>, 159 | res: Response, 160 | ) => { 161 | try { 162 | const { id } = req.params; 163 | 164 | const user = await findUserByIdService(id); 165 | 166 | if (!user) { 167 | return res.status(StatusCodes.NOT_FOUND).json({ 168 | error: "User not found", 169 | }); 170 | } 171 | 172 | const removedUserPrivateFields = omit(user.toJSON(), userModalPrivateFields); 173 | 174 | return res.status(StatusCodes.OK).json({ 175 | message: "User fetched successfully", 176 | user: removedUserPrivateFields, 177 | }); 178 | } catch (err) { 179 | logger.error(err); 180 | 181 | res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 182 | error: "Internal Server Error", 183 | }); 184 | } 185 | }; 186 | 187 | /** 188 | * This controller will mark user as verified. 189 | * this can be used by admin to mark any user as verified 190 | * 191 | * @param req request 192 | * @param res response 193 | * 194 | * @author aayushchugh, is-it-ayush 195 | */ 196 | export const patchMarkUserVerifiedHandler = async ( 197 | req: Request<PatchMarkUserVerifiedSchema["params"]>, 198 | res: Response, 199 | ) => { 200 | const { id } = req.params; 201 | 202 | try { 203 | const verifiedUser = await updateUserByIdService(id, { verified: true }); 204 | 205 | if (!verifiedUser) { 206 | return res.status(StatusCodes.NOT_FOUND).json({ 207 | error: "User not found", 208 | }); 209 | } 210 | 211 | return res.status(StatusCodes.OK).json({ 212 | message: "User verified successfully", 213 | }); 214 | } catch (err) { 215 | logger.error(err); 216 | 217 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 218 | error: "Internal Server Error", 219 | }); 220 | } 221 | }; 222 | 223 | /** 224 | * This controller will mark user as admin. 225 | * this can be used by admin to mark any user as admin 226 | * 227 | * @param req request 228 | * @param res response 229 | * 230 | * @author tharun634 231 | */ 232 | export const patchMarkUserAdminHandler = async ( 233 | req: Request<PatchMarkUserAdminSchema["params"]>, 234 | res: Response, 235 | ) => { 236 | const { id } = req.params; 237 | 238 | try { 239 | const markedAdminUser = await updateUserByIdService(id, { role: "admin" }); 240 | 241 | if (!markedAdminUser) { 242 | return res.status(StatusCodes.NOT_FOUND).json({ 243 | error: "User not found", 244 | }); 245 | } 246 | 247 | return res.status(StatusCodes.OK).json({ 248 | message: "User marked as admin successfully", 249 | }); 250 | } catch (err) { 251 | logger.error(err); 252 | 253 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 254 | error: "Internal Server Error", 255 | }); 256 | } 257 | }; 258 | 259 | /** 260 | * This controller will unsubscribe user from marketing emails 261 | * 262 | * @param req express request 263 | * @param res express response 264 | * 265 | * @author aayushchugh 266 | */ 267 | export const getUnsubscribeUserFromMarketingEmailHandler = async ( 268 | req: Request<GetUnsubscribeUserFromMarketingEmailSchema["params"]>, 269 | res: Response, 270 | ) => { 271 | try { 272 | const { id } = req.params; 273 | 274 | const updatedUser = await updateUserByIdService(id, { receive_marketing_emails: false }); 275 | 276 | if (!updatedUser) { 277 | return res.status(StatusCodes.NOT_FOUND).json({ 278 | error: "User not found", 279 | }); 280 | } 281 | 282 | res.status(StatusCodes.OK).json({ 283 | message: "User unsubscribed successfully", 284 | }); 285 | } catch (err) { 286 | logger.error(err); 287 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 288 | error: "Internal server error", 289 | }); 290 | } 291 | }; 292 | -------------------------------------------------------------------------------- /src/controllers/mail.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import axios from "axios"; 3 | import { StatusCodes } from "http-status-codes"; 4 | import { 5 | DeleteEmailFromGmailSchema, 6 | GetDraftsFromGmailSchema, 7 | GetEmailFromGmailSchema, 8 | GetEmailsFromGmailSchema, 9 | PostSendGmailSchema, 10 | } from "../schemas/mail.schema"; 11 | import { ConnectedServices, User } from "../models/user.model"; 12 | import logger from "../utils/logger.util"; 13 | import { URLSearchParams } from "url"; 14 | import { createTransport, SendMailOptions, Transporter } from "nodemailer"; 15 | import DOMPurify from "isomorphic-dompurify"; 16 | import { findUserByIdService, updateUserByIdService } from "../services/user.service"; 17 | 18 | /** 19 | * This interceptor will refresh the access token if it is expired 20 | * 21 | * @author aayushchugh 22 | */ 23 | axios.interceptors.response.use( 24 | (res) => res, 25 | async (err) => { 26 | if (err.request.host === "gmail.googleapis.com") { 27 | const originalRequest = err.config; 28 | const userEmail = originalRequest.url 29 | .split("/") 30 | .find((email: string) => email.includes("@")); 31 | const userId = originalRequest.url.split("=").pop(); 32 | 33 | if ( 34 | userEmail.split("@")[1] === "gmail.com" && 35 | err.response.status === 401 && 36 | !originalRequest._retry 37 | ) { 38 | const user = (await findUserByIdService(userId)) as User; 39 | 40 | const refreshToken = user?.connected_services.find( 41 | (service) => service.email === userEmail, 42 | )?.refresh_token; 43 | 44 | const refreshAccessTokenQuery = new URLSearchParams({ 45 | client_id: process.env.GOOGLE_CLIENT_ID as string, 46 | client_secret: process.env.GOOGLE_CLIENT_SECRET as string, 47 | refresh_token: refreshToken as string, 48 | grant_type: "refresh_token", 49 | }); 50 | 51 | const responseFromGoogle = await axios.post( 52 | `https://oauth2.googleapis.com/token?${refreshAccessTokenQuery.toString()}}`, 53 | { 54 | Headers: { 55 | "Content-Type": "application/x-www-form-urlencoded", 56 | }, 57 | }, 58 | ); 59 | 60 | const newAccessToken = responseFromGoogle.data.access_token; 61 | const connectedServices = user.connected_services; 62 | const serviceIndex = connectedServices.findIndex( 63 | (service) => service.email === userEmail, 64 | ); 65 | connectedServices[serviceIndex].access_token = newAccessToken; 66 | 67 | await updateUserByIdService(userId, { connected_services: connectedServices }); 68 | 69 | return Promise.reject(new Error("Please try again")); 70 | } 71 | } 72 | }, 73 | ); 74 | 75 | /** 76 | * This function will fetch all the emails from gmail 77 | * @param req express request 78 | * @param res express response 79 | * 80 | * @author aayushchugh 81 | */ 82 | export const getEmailsFromGmailHandler = async ( 83 | req: Request<GetEmailsFromGmailSchema["params"], {}, {}, GetEmailsFromGmailSchema["query"]>, 84 | res: Response, 85 | ) => { 86 | try { 87 | const { maxResults, pageToken, q, includeSpamTrash } = req.query; 88 | const currentConnectedService = res.locals.currentConnectedService as ConnectedServices; 89 | 90 | const fetchEmailsQueryURL = new URLSearchParams({ 91 | maxResults: maxResults || "100", 92 | pageToken: pageToken || "", 93 | q: q || "", 94 | includeSpamTrash: includeSpamTrash || "false", 95 | state: res.locals.user._id, 96 | }); 97 | 98 | const response = await axios.get( 99 | `https://gmail.googleapis.com/gmail/v1/users/${ 100 | currentConnectedService.email 101 | }/messages?${fetchEmailsQueryURL.toString()}`, 102 | { 103 | headers: { 104 | Authorization: `Bearer ${currentConnectedService.access_token}`, 105 | "Content-type": "application/json", 106 | }, 107 | }, 108 | ); 109 | 110 | return res.status(StatusCodes.OK).json({ 111 | message: "Emails fetched successfully", 112 | records: response.data.messages, 113 | size: response.data.messages.length, 114 | nextPageToken: response.data.nextPageToken, 115 | }); 116 | } catch (err: any) { 117 | logger.error(err); 118 | 119 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 120 | error: "Internal Server Error", 121 | }); 122 | } 123 | }; 124 | 125 | /** 126 | * This controller will send a email from users gmail account 127 | * @param req express request 128 | * @param res express response 129 | * 130 | * @author aayushchugh 131 | */ 132 | export const postSendGmailHandler = async ( 133 | req: Request<PostSendGmailSchema["params"], {}, PostSendGmailSchema["body"]>, 134 | res: Response, 135 | ) => { 136 | const { to, subject, html } = req.body; 137 | const currentConnectedService = res.locals.currentConnectedService as ConnectedServices; 138 | 139 | try { 140 | const cleanedHTML = DOMPurify.sanitize(html); 141 | 142 | const transporter: Transporter = createTransport({ 143 | service: "gmail", 144 | auth: { 145 | type: "OAuth2", 146 | user: currentConnectedService.email, 147 | clientId: process.env.GOOGLE_CLIENT_ID, 148 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 149 | refreshToken: currentConnectedService.refresh_token, 150 | accessToken: currentConnectedService.access_token, 151 | }, 152 | }); 153 | 154 | const mailOptions: SendMailOptions = { 155 | from: currentConnectedService.email, 156 | to, 157 | subject, 158 | html: cleanedHTML, 159 | }; 160 | 161 | await transporter.sendMail(mailOptions); 162 | 163 | return res.status(StatusCodes.OK).json({ 164 | message: "Email sent successfully", 165 | }); 166 | } catch (err) { 167 | logger.error(err); 168 | 169 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 170 | error: "Internal Server Error", 171 | }); 172 | } 173 | }; 174 | 175 | /** 176 | * This function will fetch an email from gmail 177 | * @param req express request 178 | * @param res express response 179 | * 180 | * @author tharun634 181 | */ 182 | export const getEmailFromGmailHandler = async ( 183 | req: Request<GetEmailFromGmailSchema["params"], {}, {}, {}>, 184 | res: Response, 185 | ) => { 186 | try { 187 | const { messageId } = req.params; 188 | const currentConnectedService = res.locals.currentConnectedService as ConnectedServices; 189 | 190 | const response = await axios.get( 191 | `https://gmail.googleapis.com/gmail/v1/users/${currentConnectedService.email}/messages/${messageId}?state=${res.locals.user._id}`, 192 | { 193 | headers: { 194 | Authorization: `Bearer ${currentConnectedService.access_token}`, 195 | "Content-type": "application/json", 196 | }, 197 | }, 198 | ); 199 | 200 | return res.status(StatusCodes.OK).json({ 201 | message: "Email fetched successfully", 202 | record: response.data, 203 | }); 204 | } catch (err: any) { 205 | logger.error(err.response); 206 | 207 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 208 | error: "Internal Server Error", 209 | }); 210 | } 211 | }; 212 | 213 | /** 214 | * This function will fetch drafts from gmail 215 | * @param req express request 216 | * @param res express response 217 | * 218 | * @author tharun634 219 | */ 220 | export const getDraftsFromGmailHandler = async ( 221 | req: Request<GetDraftsFromGmailSchema["params"], {}, {}, GetDraftsFromGmailSchema["query"]>, 222 | res: Response, 223 | ) => { 224 | try { 225 | const { maxResults, pageToken, q, includeSpamTrash } = req.query; 226 | const currentConnectedService = res.locals.currentConnectedService as ConnectedServices; 227 | 228 | const fetchDraftsQueryURL = new URLSearchParams({ 229 | maxResults: maxResults || "100", 230 | pageToken: pageToken || "", 231 | q: q || "", 232 | includeSpamTrash: includeSpamTrash || "false", 233 | state: res.locals.user._id, 234 | }); 235 | 236 | const response = await axios.get( 237 | `https://gmail.googleapis.com/gmail/v1/users/${ 238 | currentConnectedService.email 239 | }/drafts?${fetchDraftsQueryURL.toString()}`, 240 | { 241 | headers: { 242 | Authorization: `Bearer ${currentConnectedService.access_token}`, 243 | "Content-type": "application/json", 244 | }, 245 | }, 246 | ); 247 | 248 | return res.status(StatusCodes.OK).json({ 249 | message: "Drafts fetched successfully", 250 | records: response.data.drafts, 251 | size: response.data.drafts.length, 252 | nextPageToken: response.data.nextPageToken, 253 | }); 254 | } catch (err: any) { 255 | logger.error(err.response); 256 | 257 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 258 | error: "Internal Server Error", 259 | }); 260 | } 261 | }; 262 | 263 | export const deleteEmailFromGmailHandler = async ( 264 | req: Request<DeleteEmailFromGmailSchema["params"]>, 265 | res: Response, 266 | ) => { 267 | try { 268 | const { messageId } = req.params; 269 | const currentConnectedService = res.locals.currentConnectedService as ConnectedServices; 270 | 271 | await axios.delete( 272 | `https://gmail.googleapis.com/gmail/v1/users/${currentConnectedService.email}/messages/${messageId}?state=${res.locals.user._id}`, 273 | { 274 | headers: { 275 | Authorization: `Bearer ${currentConnectedService.access_token}`, 276 | "Content-type": "application/json", 277 | }, 278 | }, 279 | ); 280 | 281 | return res.status(StatusCodes.OK).json({ 282 | message: "Email deleted successfully", 283 | }); 284 | } catch (err) { 285 | logger.error(err); 286 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 287 | error: "Internal Server Error", 288 | }); 289 | } 290 | }; 291 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | /* Language and Environment */ 13 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 14 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 15 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 16 | "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, 17 | "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, 18 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 19 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 20 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 21 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 22 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 23 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 24 | 25 | /* Modules */ 26 | "module": "CommonJS" /* Specify what module code is generated. */, 27 | // "rootDir": "./", /* Specify the root folder within your source files. */ 28 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 29 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 30 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 31 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 32 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 33 | "types": [ 34 | "jest", 35 | "node" 36 | ] /* Specify type package names to be included without being referenced in a source file. */, 37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 38 | "resolveJsonModule": true /* Enable importing .json files */, 39 | // "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 45 | 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "./build" /* Specify an output folder for all emitted files. */, 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 77 | 78 | /* Type Checking */ 79 | "strict": true /* Enable all strict type-checking options. */, 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 81 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 84 | "strictPropertyInitialization": false /* Check for class properties that are declared but not set in the constructor. */, 85 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 86 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-) 4 | 5 | <!-- ALL-CONTRIBUTORS-BADGE:END --> 6 | 7 | # Technologies Used 8 | 9 | - ExpressJs 10 | - Mongoose 11 | - Typegoose 12 | - TypeScript 13 | - Passport 14 | 15 | # Features 16 | 17 | - Admin dashboard 18 | - User settings and or user dashboard 19 | - Send emails 20 | - Receive emails 21 | - Connections through other parties ie discord, twitter, facebook etc.. 22 | 23 | # Run locally 24 | 25 | You can setup the application on your local system by 2 methods 26 | 27 | - Docker 28 | - Manually 29 | 30 | > :warning: If you are using unix operating system 31 | > than prefix all bash commands with `sudo` 32 | 33 | ### Create new directory and clone the repository 34 | 35 | ```bash 36 | mkdir multi-email 37 | cd multi-email 38 | git clone https://github.com/MultiEmail/backend.git 39 | cd backend 40 | ``` 41 | 42 | ## Using Docker 43 | 44 | ### Prerequisite 45 | 46 | - [Docker](https://www.docker.com/) is installed on your local system 47 | - `.env` file wih all required variables (check environment variables mentioned below) 48 | 49 | ### Run Server 50 | 51 | ```bash 52 | docker compose --env-file ./.env up 53 | ``` 54 | 55 | ### To rebuild the image 56 | 57 | ```bash 58 | docker compose --env-file ./.env up --build 59 | ``` 60 | 61 | ### Create admin user 62 | 63 | List current running docker containers 64 | 65 | ```bash 66 | docker ps 67 | ``` 68 | 69 | Output 70 | 71 | ```bash 72 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 73 | d07f06c78445 backend-api "docker-entrypoint.s…" 46 minutes ago Up 46 minutes 0.0.0.0:3001->3001/tcp, :::3001->3001/tcp Server 74 | 91826c111b76 mongo:latest "docker-entrypoint.s…" 52 minutes ago Up 46 minutes 0.0.0.0:2717->27017/tcp, :::2717->27017/tcp Database 75 | 76 | ``` 77 | 78 | now copy the `CONTAINER ID` of image/container `backend-api` and replace `<container_id>` in the below mentioned commands and execute them 79 | 80 | ```bash 81 | # build and install the command line tool in docker container 82 | docker exec <container_id> yarn build 83 | docker exec <container_id> npm i -g . 84 | ``` 85 | 86 | ```bash 87 | # create new admin user in database in docker container 88 | docker exec <container_id> multi-email-admin -e <email> -u <username> -p <password> 89 | ``` 90 | 91 | ### Extra's (Port forwarding for docker containers) 92 | 93 | | Container | PORT (host) | Port (container) | 94 | | --------- | ----------- | ---------------- | 95 | | Server | 3001 | 3001 | 96 | | MongoDB | 2717 | 27017 | 97 | 98 | if you want to access database inside docker container from host than use 99 | 100 | ```bash 101 | mongosh --port 2717 102 | ``` 103 | 104 | or if you want to use [mongodb compass](https://www.mongodb.com/products/compass) than you can use 105 | this connection string 106 | 107 | ``` 108 | mongodb://localhost:2717/ 109 | ``` 110 | 111 | ## Manually 112 | 113 | ### Prerequisite 114 | 115 | - Latest [Node js](https://nodejs.org/en/) version 116 | - [Yarn](https://yarnpkg.com/) installed 117 | - [Mongodb](https://www.mongodb.com/) installed on local system 118 | - `.env` file wih all required variables (check environment variables mentioned below) 119 | 120 | ### Install all the required dependencies 121 | 122 | this project use [Yarn](https://yarnpkg.com/) as package manager 123 | 124 | ```bash 125 | yarn install 126 | ``` 127 | 128 | ### Run the server 129 | 130 | ```bash 131 | yarn dev 132 | ``` 133 | 134 | # Create admin user 135 | 136 | ```bash 137 | yarn build 138 | npm i -g . 139 | multi-email-admin -e <email> -u <username> -p <password> 140 | ``` 141 | 142 | # Environment Variables 143 | 144 | To run this project, you will need to add the following environment variables to your .env file 145 | 146 | | Name | Description | Example | 147 | | ------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------- | 148 | | BASE_URL | Base URL on which server is running | http://localhost:3001/api | 149 | | DB_URI | URI on which database is running | mongodb://localhost:27017/multiemail | 150 | | FRONTEND_URL | URI on which frontend is running | http://localhost:3000 | 151 | | GOOGLE_CLIENT_ID | Client ID obtained while creating google oauth concent screen | 758327950938-90jskrnp9b8d2e6ljpqrstd8fdl2k9fljkhchasnnrnj8.apps.googleusercontent.com | 152 | | GOOGLE_CLIENT_SECRET | Client Secret obtained while creating google oauth concent screen | GOCSPX-NL52LzLNzF6YGJxlAoeLAnGK-a6 | 153 | | GOOGLE_REDIRECT_URL | URL on which user will be redirected after google oauth | http://localhost:3001/api/auth/oauth/google/redirect | 154 | | NODE_ENV | What type of environment are you running this app in | development | 155 | | EMAIL_ID | Which ID will be used for sending email | no-reply@multiemail.com | 156 | | EMAIL_PASSWORD | Password of your email id | mystrongpassword | 157 | | EMAIL_SMTP | Email SMTP host | smtp.server.com | 158 | | ACCESS_TOKEN_PRIVATE_KEY | private RSA key which will be used to sign access token | check .env.example file | 159 | | ACCESS_TOKEN_PUBLIC_KEY | public RSA key which will be used to verify access token | check .env.example file | 160 | | REFRESH_TOKEN_PRIVATE_KEY | private RSA key which will be used to sign refresh token | check .env.example file | 161 | | REFRESH_TOKEN_PUBLIC_KEY | public RSA key which will be used to verify refresh token | check .env.example file | 162 | 163 | ### NOTEs 164 | 165 | - Your `DB_URI` must be `mongodb://mongo_db:27017/multiemail` if you are using docker 166 | - If you use gmail account as `EMAIL_ID` than you must enable [2FA](https://myaccount.google.com/signinoptions/two-step-verification/enroll-welcome?pli=1) for your google account and generate [app password](https://support.google.com/accounts/answer/185833?hl=en) and use it as `EMAIL_PASS` 167 | 168 | ### Resources for generating .env variables 169 | 170 | - You can get google credentials by following this [guide](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) 171 | - You can use [crypto tools](https://cryptotools.net/rsagen) for generating RSA keys for access and refresh tokens 172 | - RSA keys must be `1024` 173 | 174 | # Detailed docs 175 | 176 | - [Github Pages](https://multiemail.github.io/backend/) 177 | - [Wiki](https://github.com/MultiEmail/backend/wiki) 178 | 179 | # Wanna join the team? 180 | 181 | - [Discord server](https://discord.gg/gkvCYzRKEB) 182 | - [Postman team](https://www.postman.com/multiemail/workspace/muti-email-rest-api/overview) 183 | 184 | ## Contributing 185 | 186 | - Contributions make the open source community such an amazing place to learn, inspire, and create. 187 | - Any contributions you make are **truly appreciated**. 188 | - Check out our [contribution guidelines](/CONTRIBUTING.md) for more information. 189 | 190 | <h2> 191 | License 192 | </h2> 193 | 194 | <br> 195 | <p> 196 | This project is Licensed under the <a href="./LICENSE">MIT License</a>. Please go through the License atleast once before making your contribution. </p> 197 | <br> 198 | 199 | ## Contributors ✨ 200 | 201 | Thanks goes to these wonderful people ❤: 202 | 203 | <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> 204 | <!-- prettier-ignore-start --> 205 | <!-- markdownlint-disable --> 206 | <table> 207 | <tbody> 208 | <tr> 209 | <td align="center"><a href="https://shriproperty.com"><img src="https://avatars.githubusercontent.com/u/69336518?v=4?s=100" width="100px;" alt="Ayush Chugh"/><br /><sub><b>Ayush Chugh</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/issues?q=author%3Aaayushchugh" title="Bug reports">🐛</a> <a href="#ideas-aayushchugh" title="Ideas, Planning, & Feedback">🤔</a> <a href="#mentoring-aayushchugh" title="Mentoring">🧑‍🏫</a> <a href="#security-aayushchugh" title="Security">🛡️</a> <a href="https://github.com/MultiEmail/backend/commits?author=aayushchugh" title="Code">💻</a> <a href="#maintenance-aayushchugh" title="Maintenance">🚧</a> <a href="#projectManagement-aayushchugh" title="Project Management">📆</a> <a href="https://github.com/MultiEmail/backend/pulls?q=is%3Apr+reviewed-by%3Aaayushchugh" title="Reviewed Pull Requests">👀</a></td> 210 | <td align="center"><a href="https://github.com/DaatUserName"><img src="https://avatars.githubusercontent.com/u/40370496?v=4?s=100" width="100px;" alt="Toby"/><br /><sub><b>Toby</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=DaatUserName" title="Code">💻</a> <a href="https://github.com/MultiEmail/backend/pulls?q=is%3Apr+reviewed-by%3ADaatUserName" title="Reviewed Pull Requests">👀</a> <a href="#maintenance-DaatUserName" title="Maintenance">🚧</a></td> 211 | <td align="center"><a href="https://github.com/shivamvishwakarm"><img src="https://avatars.githubusercontent.com/u/80755217?v=4?s=100" width="100px;" alt="shivam vishwakarma"/><br /><sub><b>shivam vishwakarma</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=shivamvishwakarm" title="Documentation">📖</a> <a href="https://github.com/MultiEmail/backend/commits?author=shivamvishwakarm" title="Code">💻</a></td> 212 | <td align="center"><a href="https://github.com/tharun634"><img src="https://avatars.githubusercontent.com/u/53267275?v=4?s=100" width="100px;" alt="Tharun K"/><br /><sub><b>Tharun K</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=tharun634" title="Documentation">📖</a> <a href="https://github.com/MultiEmail/backend/commits?author=tharun634" title="Code">💻</a> <a href="#infra-tharun634" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td> 213 | <td align="center"><a href="https://github.com/is-it-ayush"><img src="https://avatars.githubusercontent.com/u/36449128?v=4?s=100" width="100px;" alt="Ayush"/><br /><sub><b>Ayush</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/issues?q=author%3Ais-it-ayush" title="Bug reports">🐛</a> <a href="#security-is-it-ayush" title="Security">🛡️</a> <a href="#ideas-is-it-ayush" title="Ideas, Planning, & Feedback">🤔</a> <a href="#mentoring-is-it-ayush" title="Mentoring">🧑‍🏫</a> <a href="https://github.com/MultiEmail/backend/commits?author=is-it-ayush" title="Code">💻</a> <a href="#maintenance-is-it-ayush" title="Maintenance">🚧</a> <a href="#projectManagement-is-it-ayush" title="Project Management">📆</a> <a href="https://github.com/MultiEmail/backend/pulls?q=is%3Apr+reviewed-by%3Ais-it-ayush" title="Reviewed Pull Requests">👀</a></td> 214 | <td align="center"><a href="https://www.jamesmesser.xyz"><img src="https://avatars.githubusercontent.com/u/71551059?v=4?s=100" width="100px;" alt="James"/><br /><sub><b>James</b></sub></a><br /><a href="#financial-CodesWithJames" title="Financial">💵</a> <a href="#ideas-CodesWithJames" title="Ideas, Planning, & Feedback">🤔</a> <a href="#business-CodesWithJames" title="Business development">💼</a></td> 215 | <td align="center"><a href="https://github.com/AndrewFirePvP7"><img src="https://avatars.githubusercontent.com/u/29314485?v=4?s=100" width="100px;" alt="AndrewDev"/><br /><sub><b>AndrewDev</b></sub></a><br /><a href="#ideas-AndrewFirePvP7" title="Ideas, Planning, & Feedback">🤔</a></td> 216 | </tr> 217 | <tr> 218 | <td align="center"><a href="https://arpitchugh.live/"><img src="https://avatars.githubusercontent.com/u/63435960?v=4?s=100" width="100px;" alt="Arpit Chugh"/><br /><sub><b>Arpit Chugh</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=Arpitchugh" title="Documentation">📖</a></td> 219 | <td align="center"><a href="https://github.com/drishit96"><img src="https://avatars.githubusercontent.com/u/13049630?v=4?s=100" width="100px;" alt="Drishit Mitra"/><br /><sub><b>Drishit Mitra</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=drishit96" title="Code">💻</a></td> 220 | <td align="center"><a href="https://github.com/Areadrill"><img src="https://avatars.githubusercontent.com/u/9729792?v=4?s=100" width="100px;" alt="João Mota"/><br /><sub><b>João Mota</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=Areadrill" title="Code">💻</a></td> 221 | <td align="center"><a href="https://github.com/shashankbhatgs"><img src="https://avatars.githubusercontent.com/u/76593166?v=4?s=100" width="100px;" alt="Shashank Bhat G S"/><br /><sub><b>Shashank Bhat G S</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=shashankbhatgs" title="Documentation">📖</a></td> 222 | <td align="center"><a href="https://github.com/alberturria"><img src="https://avatars.githubusercontent.com/u/32776999?v=4?s=100" width="100px;" alt="Alberto Herrera Vargas"/><br /><sub><b>Alberto Herrera Vargas</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=alberturria" title="Code">💻</a></td> 223 | <td align="center"><a href="https://vadimdez.github.io"><img src="https://avatars.githubusercontent.com/u/3748453?v=4?s=100" width="100px;" alt="Vadym Yatsyuk"/><br /><sub><b>Vadym Yatsyuk</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=VadimDez" title="Code">💻</a></td> 224 | <td align="center"><a href="https://github.com/NullableDev"><img src="https://avatars.githubusercontent.com/u/40370496?v=4?s=100" width="100px;" alt="Toby"/><br /><sub><b>Toby</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/issues?q=author%3ANullableDev" title="Bug reports">🐛</a> <a href="#security-NullableDev" title="Security">🛡️</a> <a href="#ideas-NullableDev" title="Ideas, Planning, & Feedback">🤔</a></td> 225 | </tr> 226 | <tr> 227 | <td align="center"><a href="https://yash-jain-portfolio.netlify.app/"><img src="https://avatars.githubusercontent.com/u/60182679?v=4?s=100" width="100px;" alt="yash jain"/><br /><sub><b>yash jain</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=YashJain2409" title="Code">💻</a></td> 228 | <td align="center"><a href="http://bonganibg.github.io"><img src="https://avatars.githubusercontent.com/u/88882571?v=4?s=100" width="100px;" alt="Bongani Gumbo"/><br /><sub><b>Bongani Gumbo</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=bonganibg" title="Documentation">📖</a></td> 229 | <td align="center"><a href="https://portfolio-shikhar13012001.vercel.app/"><img src="https://avatars.githubusercontent.com/u/75368010?v=4?s=100" width="100px;" alt="Shikhar"/><br /><sub><b>Shikhar</b></sub></a><br /><a href="https://github.com/MultiEmail/backend/commits?author=shikhar13012001" title="Code">💻</a></td> 230 | </tr> 231 | </tbody> 232 | <tfoot> 233 | <tr> 234 | <td align="center" size="13px" colspan="7"> 235 | <img src="https://raw.githubusercontent.com/all-contributors/all-contributors-cli/1b8533af435da9854653492b1327a23a4dbd0a10/assets/logo-small.svg"> 236 | <a href="https://all-contributors.js.org/docs/en/bot/usage">Add your contributions</a> 237 | </img> 238 | </td> 239 | </tr> 240 | </tfoot> 241 | </table> 242 | 243 | <!-- markdownlint-restore --> 244 | <!-- prettier-ignore-end --> 245 | 246 | <!-- ALL-CONTRIBUTORS-LIST:END --> 247 | 248 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 249 | -------------------------------------------------------------------------------- /src/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { StatusCodes } from "http-status-codes"; 3 | import { 4 | ForgotPasswordSchema, 5 | LoginSchema, 6 | RedirectToGoogleConsentScreenHandlerSchema, 7 | ResetPasswordSchema, 8 | SignupSchema, 9 | VerifyUserSchema, 10 | } from "../schemas/auth.schema"; 11 | import { 12 | getGoogleOAuthTokensService, 13 | signAccessTokenService, 14 | signRefreshTokenService, 15 | } from "../services/auth.service"; 16 | import { 17 | createUserService, 18 | findUserByEitherEmailOrUsernameService, 19 | findUserByEmailService, 20 | findUserByIdService, 21 | } from "../services/user.service"; 22 | import { findSessionByIdService } from "../services/session.service"; 23 | import { sendEmail } from "../utils/email.util"; 24 | import { verifyJWT } from "../utils/jwt.util"; 25 | import logger from "../utils/logger.util"; 26 | import { omit } from "lodash"; 27 | import { URLSearchParams } from "url"; 28 | import { userModalPrivateFields } from "../models/user.model"; 29 | import { generateRandomOTP } from "../utils/otp.util"; 30 | import { decode } from "jsonwebtoken"; 31 | 32 | /** 33 | * This controller will create new account of user in database 34 | * and send an email to the given email that will contain a verification code (OTP) 35 | * which will be verified in `/api/auth/verify/:email/:verificationCode` route 36 | * 37 | * @param req request 38 | * @param res response 39 | * 40 | * @author aayushchugh 41 | */ 42 | export const signupHandler = async (req: Request<{}, {}, SignupSchema["body"]>, res: Response) => { 43 | const { password, receiveMarketingEmails, acceptedTermsAndConditions } = req.body; 44 | const username = req.body.username.toLowerCase().trim(); 45 | const email = req.body.email.toLowerCase().trim(); 46 | 47 | try { 48 | const createdUser = await createUserService({ 49 | username, 50 | email, 51 | password, 52 | role: "user", 53 | verified: false, 54 | receive_marketing_emails: receiveMarketingEmails, 55 | accepted_terms_and_conditions: acceptedTermsAndConditions, 56 | }); 57 | 58 | const token = await signAccessTokenService(createdUser); 59 | 60 | sendEmail( 61 | email, 62 | "Verify your Multi Email account", 63 | `<h2>Welcome to Multi Email</h2> 64 | <h4>please visit this URL and enter your OTP</h4> 65 | <p> 66 | OTP: ${createdUser.verification_code} 67 | </p> 68 | <p> 69 | <a href="${process.env.FRONTEND_URL}/verify?v=${createdUser.verification_code}&t=${token}">Verify My Account</a> 70 | </p> 71 | `, 72 | ); 73 | 74 | return res.status(StatusCodes.CREATED).json({ 75 | message: "User created successfully", 76 | }); 77 | } catch (err: any) { 78 | if (err.code === 11000) { 79 | return res.status(StatusCodes.CONFLICT).json({ 80 | error: "User with same email or username already exists", 81 | }); 82 | } 83 | // @author is-it-ayush 84 | // fix: Only log error's if they are unknown errors to prevent console spam. 85 | logger.error(err); 86 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 87 | error: "Internal server error", 88 | }); 89 | } 90 | }; 91 | 92 | /** 93 | * This controller will verify the user 94 | * this is needed so that we can verify that email given by 95 | * user is correct 96 | * 97 | * @param req request 98 | * @param res response 99 | * 100 | * @author aayushchugh 101 | */ 102 | export const verifyUserHandler = async ( 103 | req: Request<VerifyUserSchema["params"]>, 104 | res: Response, 105 | ) => { 106 | const { verificationCode } = req.params; 107 | 108 | try { 109 | // QUESTION: should we use username to find the user or email? 110 | 111 | const { user } = res.locals; 112 | 113 | if (!user) { 114 | return res.status(StatusCodes.NOT_FOUND).json({ 115 | error: "User not found", 116 | }); 117 | } 118 | 119 | if (user.verified) { 120 | return res.status(StatusCodes.OK).json({ 121 | message: "User verified successfully", 122 | }); 123 | } 124 | 125 | if (!parseInt(verificationCode)) { 126 | return res.status(StatusCodes.UNPROCESSABLE_ENTITY).json({ 127 | error: "Invalid verification code", 128 | }); 129 | } 130 | 131 | if (user.verificationCode !== +verificationCode) { 132 | return res.status(StatusCodes.UNAUTHORIZED).json({ 133 | error: "Invalid verification code", 134 | }); 135 | } 136 | 137 | user.verified = true; 138 | await user.save(); 139 | 140 | return res.status(StatusCodes.OK).json({ 141 | message: "User verified successfully", 142 | }); 143 | } catch (err) { 144 | logger.error(err); 145 | 146 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 147 | error: "Internal server error", 148 | }); 149 | } 150 | }; 151 | 152 | /** 153 | * This controller will be used to login the user 154 | * and will create new session in database 155 | * 156 | * @param req request 157 | * @param res response 158 | * 159 | * @author aayushchugh 160 | */ 161 | export const loginHandler = async (req: Request<{}, {}, LoginSchema["body"]>, res: Response) => { 162 | const { email, password, username } = req.body; 163 | try { 164 | if (!email && !username) { 165 | return res.status(StatusCodes.UNAUTHORIZED).json({ 166 | error: "You must provide a valid email or username", 167 | }); 168 | } 169 | 170 | if (email && username) { 171 | return res.status(StatusCodes.BAD_REQUEST).json({ 172 | error: "You can only provide one thing either email or username", 173 | }); 174 | } 175 | 176 | const user = await findUserByEitherEmailOrUsernameService( 177 | email as string, 178 | username as string, 179 | ); 180 | 181 | if (!user) { 182 | return res.status(StatusCodes.NOT_FOUND).json({ 183 | error: "User not found", 184 | }); 185 | } 186 | 187 | const isPasswordCorrect = await user.comparePassword(password); 188 | 189 | if (!isPasswordCorrect) { 190 | return res.status(StatusCodes.UNAUTHORIZED).json({ 191 | error: "Invalid credentials", 192 | }); 193 | } 194 | 195 | // @author is-it-ayush 196 | //fix: if user is not verified then don't allow him to login 197 | if (!user.verified) { 198 | return res.status(StatusCodes.UNAUTHORIZED).json({ 199 | error: "User is not verified", 200 | }); 201 | } 202 | 203 | // sign access and refresh token 204 | const accessToken = await signAccessTokenService(user); 205 | const refreshToken = await signRefreshTokenService(user._id); 206 | 207 | return res.status(StatusCodes.OK).json({ 208 | message: "User logged in successfully", 209 | access_token: accessToken, 210 | refresh_token: refreshToken, 211 | role: user.role, 212 | }); 213 | } catch (err) { 214 | logger.error(err); 215 | 216 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 217 | error: "Internal server error", 218 | }); 219 | } 220 | }; 221 | 222 | /** 223 | * This controller will logout the user by invalidating the 224 | * current session 225 | * 226 | * @param req request 227 | * @param res response 228 | * 229 | * @author aayushchugh 230 | */ 231 | export const logoutHandler = async (req: Request, res: Response) => { 232 | const refreshToken = req.headers["x-refresh"] as string; 233 | 234 | if (!refreshToken) { 235 | return res.status(StatusCodes.UNAUTHORIZED).json({ 236 | error: "Invalid refresh token", 237 | }); 238 | } 239 | 240 | try { 241 | // verify if refresh token is valid 242 | const decoded = await verifyJWT<{ session: string }>( 243 | refreshToken, 244 | "REFRESH_TOKEN_PUBLIC_KEY", 245 | ); 246 | 247 | if (!decoded) { 248 | return res.status(StatusCodes.UNAUTHORIZED).json({ 249 | error: "Invalid refresh token", 250 | }); 251 | } 252 | 253 | // make session invalid 254 | const session = await findSessionByIdService(decoded.session); 255 | 256 | if (!session || !session.valid) { 257 | return res.status(StatusCodes.UNAUTHORIZED).json({ 258 | error: "Session is not valid", 259 | }); 260 | } 261 | 262 | session.valid = false; 263 | await session.save(); 264 | 265 | res.status(StatusCodes.OK).json({ 266 | message: "User logged out successfully", 267 | }); 268 | } catch (err) { 269 | logger.info(err); 270 | 271 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 272 | error: "Internal server error", 273 | }); 274 | } 275 | }; 276 | 277 | /** 278 | * This controller will get current user from 279 | * access token 280 | * 281 | * @param req request 282 | * @param res response 283 | * 284 | * @author aayushchugh 285 | */ 286 | export const getCurrentUserHandler = (req: Request, res: Response) => { 287 | const { user } = res.locals; 288 | 289 | if (!user) { 290 | // @author is-it-ayush 291 | //fix: if user is not found then return 401 status code 292 | return res.status(StatusCodes.UNAUTHORIZED).json({ 293 | message: "User is not logged In", 294 | user: null, 295 | }); 296 | } 297 | 298 | const removePrivateFieldsFromUser = omit(user.toJSON(), userModalPrivateFields); 299 | 300 | return res.status(StatusCodes.OK).json({ 301 | message: "User is logged In", 302 | user: removePrivateFieldsFromUser, 303 | }); 304 | }; 305 | 306 | /** 307 | * This controller will send a forgot password verification code 308 | * to the given email which will be verified in `/api/auth/resetpassword/:email/:passwordResetCode` 309 | * route 310 | * 311 | * @param req request 312 | * @param res response 313 | * 314 | * @author aayushchugh 315 | */ 316 | export const forgotPasswordHandler = async ( 317 | req: Request<{}, {}, ForgotPasswordSchema["body"]>, 318 | res: Response, 319 | ) => { 320 | const { email } = req.body; 321 | 322 | try { 323 | const user = await findUserByEmailService(email.trim()); 324 | 325 | if (!user) { 326 | return res.status(StatusCodes.NOT_FOUND).json({ 327 | error: "User not found", 328 | }); 329 | } 330 | 331 | if (!user.verified) { 332 | return res.status(StatusCodes.FORBIDDEN).json({ 333 | error: "User is not verified", 334 | }); 335 | } 336 | 337 | // generate password reset code and send that to users email 338 | const passwordResetCode = generateRandomOTP(); 339 | user.password_reset_code = passwordResetCode; 340 | 341 | /** 342 | * @author is-it-ayush 343 | * fix: send email to user with password reset code before saving the user. 344 | * fix: wrapped the save method in a try catch block to handle errors. 345 | */ 346 | try { 347 | await sendEmail( 348 | email, 349 | "OTP to password reset for Multi Email", 350 | `Your OTP to reset password is ${passwordResetCode}`, 351 | ); 352 | } catch (err) { 353 | logger.error(err); 354 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 355 | error: "Internal server error", 356 | }); 357 | } 358 | 359 | await user.save(); 360 | 361 | return res.status(StatusCodes.OK).json({ 362 | message: "Password reset code sent to your email", 363 | }); 364 | } catch (err) { 365 | logger.error(err); 366 | 367 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 368 | error: "Internal server error", 369 | }); 370 | } 371 | }; 372 | 373 | /** 374 | * This controller will update the password of user in database 375 | * 376 | * @param req request 377 | * @param res response 378 | * 379 | * @author aayushchugh 380 | */ 381 | export const resetPasswordHandler = async ( 382 | req: Request<ResetPasswordSchema["params"], {}, ResetPasswordSchema["body"]>, 383 | res: Response, 384 | ) => { 385 | const { email, passwordResetCode } = req.params; 386 | const { password } = req.body; 387 | 388 | try { 389 | const user = await findUserByEmailService(email); 390 | 391 | if (!user) { 392 | return res.status(StatusCodes.NOT_FOUND).json({ 393 | error: "User not found", 394 | }); 395 | } 396 | 397 | if ( 398 | !user?.password_reset_code || 399 | user?.password_reset_code !== parseInt(passwordResetCode) 400 | ) { 401 | return res.status(StatusCodes.FORBIDDEN).json({ 402 | error: "Invalid password reset code", 403 | }); 404 | } 405 | 406 | user.password_reset_code = null; 407 | 408 | // NOTE: password will be hashed in user model 409 | user.password = password; 410 | 411 | await user.save(); 412 | 413 | return res.status(StatusCodes.OK).json({ 414 | message: "Password reset successfully", 415 | }); 416 | } catch (err) { 417 | logger.error(err); 418 | 419 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 420 | error: "Internal Server Error", 421 | }); 422 | } 423 | }; 424 | 425 | /** 426 | * This controller will refresh the `access_token` 427 | * use the `refresh_token` sent in header 428 | * 429 | * @param req request 430 | * @param res response 431 | * 432 | * @author aayushchugh 433 | */ 434 | export const refreshAccessTokenHandler = async (req: Request, res: Response) => { 435 | const refreshToken = req.headers["x-refresh"] as string; 436 | 437 | if (!refreshToken) { 438 | return res.status(StatusCodes.UNAUTHORIZED).json({ 439 | error: "Invalid refresh token", 440 | }); 441 | } 442 | 443 | try { 444 | const decoded = await verifyJWT<{ session: string }>( 445 | refreshToken, 446 | "REFRESH_TOKEN_PUBLIC_KEY", 447 | ); 448 | 449 | if (!decoded) { 450 | return res.status(StatusCodes.UNAUTHORIZED).json({ 451 | error: "Invalid refresh token", 452 | }); 453 | } 454 | 455 | const session = await findSessionByIdService(decoded.session); 456 | 457 | if (!session || !session.valid) { 458 | return res.status(StatusCodes.UNAUTHORIZED).json({ 459 | error: "Session is not valid", 460 | }); 461 | } 462 | 463 | const user = session.user && (await findUserByIdService(session.user.toString())); 464 | 465 | if (!user) { 466 | return res.status(StatusCodes.NOT_FOUND).json({ 467 | error: "User not found", 468 | }); 469 | } 470 | 471 | const accessToken = await signAccessTokenService(user); 472 | 473 | return res.status(StatusCodes.OK).json({ 474 | access_token: accessToken, 475 | }); 476 | } catch (err) { 477 | logger.error(err); 478 | 479 | return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ 480 | error: "Internal Server Error", 481 | }); 482 | } 483 | }; 484 | 485 | /** 486 | * This controller will redirect user to google consent screen 487 | * @param req express request 488 | * @param res express response 489 | * 490 | * @author aayushchugh 491 | */ 492 | export const redirectToGoogleConsentScreenHandler = ( 493 | req: Request<{}, {}, {}, RedirectToGoogleConsentScreenHandlerSchema["query"]>, 494 | res: Response, 495 | ) => { 496 | const { id } = req.query; 497 | const rootUrl = "https://accounts.google.com/o/oauth2/v2/auth"; 498 | const redirectUrl = process.env.GOOGLE_REDIRECT_URL as string; 499 | const clientId = process.env.GOOGLE_CLIENT_ID as string; 500 | 501 | const options = { 502 | redirect_uri: redirectUrl, 503 | client_id: clientId, 504 | access_type: "offline", 505 | response_type: "code", 506 | prompt: "consent", 507 | scope: [ 508 | "https://www.googleapis.com/auth/userinfo.profile", 509 | "https://www.googleapis.com/auth/userinfo.email", 510 | "https://mail.google.com/", 511 | "https://www.googleapis.com/auth/gmail.modify", 512 | "https://www.googleapis.com/auth/gmail.readonly", 513 | ].join(" "), 514 | state: id, 515 | }; 516 | const qs = new URLSearchParams(options); 517 | 518 | return res.status(StatusCodes.TEMPORARY_REDIRECT).redirect(`${rootUrl}?${qs.toString()}`); 519 | }; 520 | 521 | /** 522 | * This controller will add new service for user 523 | * @param req express request 524 | * @param res express response 525 | * 526 | * @author aayushchugh 527 | */ 528 | export const googleOauthHandler = async (req: Request, res: Response) => { 529 | interface IGoogleUser { 530 | iss: string; 531 | azp: string; 532 | aud: string; 533 | sub: string; 534 | email: string; 535 | email_verified: boolean; 536 | at_hash: string; 537 | name: string; 538 | picture: string; 539 | given_name: string; 540 | family_name: string; 541 | locale: string; 542 | iat: number; 543 | exp: number; 544 | } 545 | 546 | const FRONTEND_URL = process.env.FRONTEND_URL as string; 547 | 548 | try { 549 | const code = req.query.code as string; 550 | const id = req.query.state as string; 551 | const tokens = await getGoogleOAuthTokensService(code); 552 | const googleUser = decode(tokens.id_token) as IGoogleUser; 553 | 554 | const foundUser = await findUserByIdService(id); 555 | 556 | if (!foundUser) { 557 | // TODO: redirect to connect accounts path on frontend 558 | return res.status(StatusCodes.NOT_FOUND).redirect(FRONTEND_URL); 559 | } 560 | 561 | // if account is already added than redirect to frontend 562 | if (foundUser.connected_services.filter((s) => s.email === foundUser.email).length) { 563 | return res.status(StatusCodes.CONFLICT).redirect(FRONTEND_URL); 564 | } 565 | 566 | if (tokens.refresh_token) { 567 | foundUser.connected_services.push({ 568 | service: "google", 569 | refresh_token: tokens.refresh_token, 570 | access_token: tokens.access_token, 571 | email: googleUser.email, 572 | }); 573 | 574 | await foundUser.save(); 575 | } 576 | 577 | // TODO: redirect to success page on frontend 578 | res.status(StatusCodes.PERMANENT_REDIRECT).redirect(FRONTEND_URL); 579 | } catch (err) { 580 | logger.error(err); 581 | // TODO: redirect should be to frontend connect account page 582 | return res.redirect(FRONTEND_URL); 583 | } 584 | }; 585 | -------------------------------------------------------------------------------- /src/tests/endpoints.test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const supertest = require("supertest"); 3 | const app = require("../app"); 4 | const logger = require("../utils/logger.util"); 5 | const { MongoMemoryServer } = require("mongodb-memory-server"); 6 | const { findUserByEmailService, createUserService } = require("../services/user.service"); 7 | const { expect } = require("@jest/globals"); 8 | 9 | describe("Endpoints Test", () => { 10 | let user; 11 | let adminUser; 12 | let mongod; 13 | 14 | beforeAll(async () => { 15 | mongod = await MongoMemoryServer.create({ 16 | instance: { 17 | port: 27016, 18 | dbName: "multiemail", 19 | }, 20 | }); 21 | 22 | await mongoose.connect(mongod.getUri()); 23 | console.log(`Connected to ${mongod.getUri()}`); 24 | 25 | await app.listen(process.env.PORT || 3001, () => { 26 | console.log(`API started on port ${process.env.PORT || 3001}`); 27 | }); 28 | 29 | // Generate a random user 30 | user = await createUser(); 31 | adminUser = await createUser(); 32 | console.log(`Generated Test User: ${JSON.stringify(user)}`); 33 | console.log(`Generated Test Admin User: ${JSON.stringify(adminUser)}`); 34 | 35 | // Creating a Admin User 36 | createUserService({ 37 | role: "admin", 38 | username: adminUser.username, 39 | email: adminUser.email, 40 | verified: true, 41 | password: adminUser.password, 42 | accepted_terms_and_conditions: true, 43 | receive_marketing_emails: true, 44 | }); 45 | }); 46 | 47 | describe("/auth", () => { 48 | describe("POST /auth/signup", () => { 49 | it("should not create a new user if passwords do not match", async () => { 50 | let res = await supertest(app) 51 | .post("/api/auth/signup") 52 | .set("Accept", "application/json") 53 | .send({ 54 | ...user, 55 | cpassword: "1234567", 56 | }); 57 | 58 | expect(res.status).toBe(401); 59 | expect(res.body).toHaveProperty("error"); 60 | expect(res.body.error).toEqual("Password and Confirm password do not match"); 61 | }); 62 | 63 | it("should not create a new user if email is invalid", async () => { 64 | const res = await supertest(app) 65 | .post("/api/auth/signup") 66 | .set("Accept", "application/json") 67 | .send({ 68 | username: user.username, 69 | email: "test", 70 | password: user.password, 71 | cpassword: user.password, 72 | }); 73 | expect(res.status).toEqual(422); 74 | expect(res.body).toHaveProperty("error"); 75 | expect(res.body.error).toEqual("Please enter a valid email"); 76 | }); 77 | 78 | it("should create a new user", async () => { 79 | const res = await supertest(app) 80 | .post("/api/auth/signup") 81 | .set("Accept", "application/json") 82 | .send({ 83 | ...user, 84 | cpassword: user.password, 85 | }); 86 | 87 | expect(res.statusCode).toEqual(201); 88 | expect(res.body).toHaveProperty("message"); 89 | expect(res.body.message).toEqual("User created successfully"); 90 | }); 91 | 92 | it("should verify user", async () => { 93 | // Login with Admin and save the access token and refresh token for future test use. 94 | const adminLoginRes = await supertest(app) 95 | .post("/api/auth/login") 96 | .set("Accept", "application/json") 97 | .send({ 98 | email: adminUser.email, 99 | password: adminUser.password, 100 | }); 101 | 102 | expect(adminLoginRes.status).toEqual(200); 103 | expect(adminLoginRes.body).toHaveProperty("message"); 104 | expect(adminLoginRes.body.message).toEqual("User logged in successfully"); 105 | 106 | adminUser.access_token = adminLoginRes.body.access_token; 107 | adminUser.refresh_token = adminLoginRes.body.refresh_token; 108 | 109 | // Internal Service Call to fetch ID of the user. 110 | const userDocument = await findUserByEmailService(user.email); 111 | const userId = userDocument._id; 112 | 113 | await supertest(app) 114 | .patch("/api/admin/users/markverified/" + userId) 115 | .set("Accept", "application/json") 116 | .set("Authorization", "Bearer " + adminUser.access_token); 117 | }); 118 | 119 | it("should not create a new user if email or username already exists", async () => { 120 | const res = await supertest(app) 121 | .post("/api/auth/signup") 122 | .set("Accept", "application/json") 123 | .send({ 124 | ...user, 125 | cpassword: user.password, 126 | }); 127 | expect(res.status).toEqual(409); 128 | expect(res.body).toHaveProperty("error"); 129 | expect(res.body.error).toEqual("User with same email or username already exists"); 130 | }); 131 | }); 132 | 133 | describe("POST /auth/login", () => { 134 | it("should not login if the user is not found", async () => { 135 | let newRandomUser = await createUser(); 136 | const res = await supertest(app) 137 | .post("/api/auth/login") 138 | .set("Accept", "application/json") 139 | .send({ 140 | email: newRandomUser.email, 141 | password: newRandomUser.password, 142 | }); 143 | 144 | expect(res.status).toEqual(404); 145 | expect(res.body).toHaveProperty("error"); 146 | expect(res.body.error).toEqual("User not found"); 147 | }); 148 | 149 | it("should not login if the user is not verified", async () => { 150 | // Create a new user to test the if the login if the user is not verified. 151 | let newRandomUser = await createUser(); 152 | const signupRequest = await supertest(app) 153 | .post("/api/auth/signup") 154 | .set("Accept", "application/json") 155 | .send({ 156 | ...newRandomUser, 157 | cpassword: newRandomUser.password, 158 | }); 159 | 160 | expect(signupRequest.statusCode).toEqual(201); 161 | expect(signupRequest.body).toHaveProperty("message"); 162 | expect(signupRequest.body.message).toEqual("User created successfully"); 163 | 164 | // Login with the newly created unverified user. 165 | const res = await supertest(app) 166 | .post("/api/auth/login") 167 | .set("Accept", "application/json") 168 | .send({ 169 | email: newRandomUser.email, 170 | password: newRandomUser.password, 171 | }); 172 | 173 | expect(res.status).toEqual(401); 174 | expect(res.body).toHaveProperty("error"); 175 | expect(res.body.error).toEqual("User is not verified"); 176 | }); 177 | 178 | it("should not login if the password is incorrect", async () => { 179 | const res = await supertest(app) 180 | .post("/api/auth/login") 181 | .set("Accept", "application/json") 182 | .send({ 183 | email: user.email, 184 | password: "1234567", 185 | }); 186 | 187 | expect(res.status).toEqual(401); 188 | expect(res.body).toHaveProperty("error"); 189 | expect(res.body.error).toEqual("Invalid credentials"); 190 | }); 191 | 192 | it("should login if the credentials are correct", async () => { 193 | const res = await supertest(app) 194 | .post("/api/auth/login") 195 | .set("Accept", "application/json") 196 | .send({ 197 | email: user.email, 198 | password: user.password, 199 | }); 200 | 201 | // Save the access token and refresh token for future test use. 202 | user.access_token = res.body.access_token; 203 | user.refresh_token = res.body.refresh_token; 204 | 205 | expect(res.status).toEqual(200); 206 | expect(res.body).toHaveProperty("message"); 207 | expect(res.body.message).toEqual("User logged in successfully"); 208 | }); 209 | }); 210 | 211 | describe("GET /auth/me", () => { 212 | it("should not get the user details if user is logged out", async () => { 213 | const res = await supertest(app) 214 | .get("/api/auth/me") 215 | .set("Accept", "application/json"); 216 | expect(res.status).toEqual(401); 217 | expect(res.body).toHaveProperty("error"); 218 | expect(res.body.error).toEqual("User is not logged in"); 219 | }); 220 | 221 | it("should get the user details if user is logged in", async () => { 222 | const res = await supertest(app) 223 | .get("/api/auth/me") 224 | .set("Accept", "application/json") 225 | .set("Authorization", `Bearer ${user.access_token}`); 226 | 227 | expect(res.status).toEqual(200); 228 | expect(res.body).toHaveProperty("message"); 229 | expect(res.body).toHaveProperty("user"); 230 | expect(res.body.message).toEqual("User is logged In"); 231 | expect(res.body.user).toHaveProperty("email"); 232 | expect(res.body.user.email).toEqual(user.email); 233 | }); 234 | }); 235 | 236 | describe("GET /auth/refresh", () => { 237 | it("should not refresh the access token if the refresh token is not provided", async () => { 238 | const res = await supertest(app) 239 | .get("/api/auth/refresh") 240 | .set("Accept", "application/json"); 241 | expect(res.status).toEqual(401); 242 | expect(res.body).toHaveProperty("error"); 243 | expect(res.body.error).toEqual("Invalid refresh token"); 244 | }); 245 | 246 | it("should not refresh the access token if the refresh token is invalid", async () => { 247 | const res = await supertest(app) 248 | .get("/api/auth/refresh") 249 | .set("Accept", "application/json") 250 | .set("x-refresh", user.access_token + "invalidJWTString"); 251 | expect(res.status).toEqual(401); 252 | expect(res.body).toHaveProperty("error"); 253 | expect(res.body.error).toEqual("Invalid refresh token"); 254 | }); 255 | 256 | it("should refresh the access token if the refresh token is valid", async () => { 257 | const res = await supertest(app) 258 | .get("/api/auth/refresh") 259 | .set("Accept", "application/json") 260 | .set("x-refresh", user.refresh_token); 261 | expect(res.status).toEqual(200); 262 | expect(res.body).toHaveProperty("access_token"); 263 | expect(res.body.access_token).toBeTruthy(); 264 | }); 265 | }); 266 | 267 | describe("GET /auth/logout", () => { 268 | it("should not logout if the refresh token is invalid", async () => { 269 | const res = await supertest(app) 270 | .get("/api/auth/logout") 271 | .set("Accept", "application/json") 272 | .set("x-refresh", user.access_token + "invalidJWTString"); 273 | expect(res.status).toEqual(401); 274 | expect(res.body).toHaveProperty("error"); 275 | expect(res.body.error).toEqual("Invalid refresh token"); 276 | }); 277 | 278 | it("should logout if the refresh token is valid", async () => { 279 | const res = await supertest(app) 280 | .get("/api/auth/logout") 281 | .set("Accept", "application/json") 282 | .set("x-refresh", user.refresh_token); 283 | expect(res.status).toEqual(200); 284 | expect(res.body).toHaveProperty("message"); 285 | expect(res.body.message).toEqual("User logged out successfully"); 286 | }); 287 | }); 288 | 289 | describe("POST /auth/forgotpassword", () => { 290 | it("should not send the reset password link if the email is not provided", async () => { 291 | const res = await supertest(app) 292 | .post("/api/auth/forgotpassword") 293 | .set("Accept", "application/json"); 294 | 295 | expect(res.status).toEqual(400); 296 | expect(res.body).toHaveProperty("error"); 297 | expect(res.body.error).toEqual("Email is required"); 298 | }); 299 | 300 | it("should not send the reset password link if the email is invalid", async () => { 301 | const res = await supertest(app) 302 | .post("/api/auth/forgotpassword") 303 | .set("Accept", "application/json") 304 | .send({ 305 | email: "invalidEmail", 306 | }); 307 | expect(res.status).toEqual(422); 308 | expect(res.body).toHaveProperty("error"); 309 | expect(res.body.error).toEqual("Please enter a valid email"); 310 | }); 311 | 312 | it("should not send the reset password link if user is not found", async () => { 313 | const newRandomUser = await createUser(); 314 | const res = await supertest(app) 315 | .post("/api/auth/forgotpassword") 316 | .set("Accept", "application/json") 317 | .send({ 318 | email: newRandomUser.email, 319 | }); 320 | 321 | expect(res.status).toEqual(404); 322 | expect(res.body).toHaveProperty("error"); 323 | expect(res.body.error).toEqual("User not found"); 324 | }); 325 | 326 | it("should not send the reset password link if the email is not verified", async () => { 327 | const newRandomUser = await createUser(); 328 | 329 | const userSignup = await supertest(app) 330 | .post("/api/auth/signup") 331 | .set("Accept", "application/json") 332 | .send({ 333 | ...newRandomUser, 334 | cpassword: newRandomUser.password, 335 | }); 336 | 337 | expect(userSignup.statusCode).toEqual(201); 338 | expect(userSignup.body).toHaveProperty("message"); 339 | expect(userSignup.body.message).toEqual("User created successfully"); 340 | 341 | const res = await supertest(app) 342 | .post("/api/auth/forgotpassword") 343 | .set("Accept", "application/json") 344 | .send({ 345 | email: newRandomUser.email, 346 | }); 347 | 348 | expect(res.status).toEqual(403); 349 | expect(res.body).toHaveProperty("error"); 350 | expect(res.body.error).toEqual("User is not verified"); 351 | }); 352 | 353 | it("should send the reset password link if the email is valid", async () => { 354 | const res = await supertest(app) 355 | .post("/api/auth/forgotpassword") 356 | .set("Accept", "application/json") 357 | .send({ 358 | email: user.email, 359 | }); 360 | 361 | expect(res.status).toEqual(200); 362 | expect(res.body).toHaveProperty("message"); 363 | expect(res.body.message).toEqual("Password reset code sent to your email"); 364 | }); 365 | }); 366 | 367 | describe("PATCH /auth/resetpassword/:email/:passwordResetCode", () => { 368 | it("should not reset the password if the email or verification code is invalid", async () => { 369 | const res = await supertest(app) 370 | .patch(`/api/auth/resetpassword/${"invalidEmail"}/${"1234"}`) 371 | .set("Accept", "application/json") 372 | .send(); 373 | expect(res.status).toEqual(422); 374 | expect(res.body).toHaveProperty("error"); 375 | }); 376 | 377 | it("should not send if the user is not found", async () => { 378 | const newRandomUser = await createUser(); 379 | const res = await supertest(app) 380 | .patch(`/api/auth/resetpassword/${newRandomUser.email}/${"1234"}`) 381 | .set("Accept", "application/json") 382 | .send({ 383 | password: newRandomUser.password, 384 | cpassword: newRandomUser.password, 385 | }); 386 | expect(res.status).toEqual(404); 387 | expect(res.body).toHaveProperty("error"); 388 | expect(res.body.error).toEqual("User not found"); 389 | }); 390 | 391 | it("should not reset the password if the password do not match", async () => { 392 | let userDocument = await findUserByEmailService(user.email); 393 | const res = await supertest(app) 394 | .patch( 395 | `/api/auth/resetpassword/${user.email}/${userDocument.password_reset_code}`, 396 | ) 397 | .set("Accept", "application/json") 398 | .send({ 399 | password: user.password, 400 | cpassword: "invalidPassword", 401 | }); 402 | expect(res.status).toEqual(401); 403 | expect(res.body).toHaveProperty("error"); 404 | expect(res.body.error).toEqual("Password and confirm password do not match"); 405 | }); 406 | 407 | it("should reset the password if the email and verification code is valid", async () => { 408 | let userDocument = await findUserByEmailService(user.email); 409 | const res = await supertest(app) 410 | .patch( 411 | `/api/auth/resetpassword/${user.email}/${userDocument.password_reset_code}`, 412 | ) 413 | .set("Accept", "application/json") 414 | .send({ 415 | password: user.password, 416 | cpassword: user.password, 417 | }); 418 | expect(res.status).toEqual(200); 419 | expect(res.body).toHaveProperty("message"); 420 | expect(res.body.message).toEqual("Password reset successfully"); 421 | }); 422 | }); 423 | }); 424 | 425 | describe("/users", () => { 426 | describe("GET /users", () => { 427 | it("should return all users", async () => { 428 | const res = await supertest(app) 429 | .get("/api/admin/users") 430 | .set("Accept", "application/json") 431 | .set("Authorization", `Bearer ${adminUser.access_token}`) 432 | .send(); 433 | expect(res.status).toEqual(200); 434 | expect(res.body).toHaveProperty("message"); 435 | expect(res.body.message).toEqual("Users fetched successfully"); 436 | expect(res.body).toHaveProperty("records"); 437 | // Since we have created a user in the beforeAll hook, we dont expect the length to be 0. Hence ensuring. 438 | expect(res.body.records.length).not.toEqual(0); 439 | }); 440 | }); 441 | 442 | /** 443 | * Will move them in a future commit to /admin endpoint as directed by issue#90. 444 | * Current: /users 445 | */ 446 | describe("DELETE /users/:id", () => { 447 | it("should not delete if user is not found, i.e. invalid ID", async () => { 448 | const res = await supertest(app) 449 | .delete(`/api/admin/users/${"6339cebe708e3a1198f1997b"}`) 450 | .set("Accept", "application/json") 451 | .set("Authorization", `Bearer ${adminUser.access_token}`) 452 | .send(); 453 | 454 | expect(res.status).toEqual(404); 455 | expect(res.body).toHaveProperty("error"); 456 | expect(res.body.error).toEqual("User not found"); 457 | }); 458 | 459 | it("should delete the user if the user is found", async () => { 460 | // Generate a random user. 461 | const newUser = await createUser(); 462 | 463 | // Signup a new user to delete later. 464 | const signupRes = await supertest(app) 465 | .post("/api/auth/signup") 466 | .set("Accept", "application/json") 467 | .send({ 468 | ...newUser, 469 | cpassword: newUser.password, 470 | }); 471 | 472 | expect(signupRes.statusCode).toEqual(201); 473 | expect(signupRes.body).toHaveProperty("message"); 474 | expect(signupRes.body.message).toEqual("User created successfully"); 475 | 476 | // Internal Service Call to fetch ID of the user. 477 | const userDocument = await findUserByEmailService(newUser.email); 478 | const userId = userDocument._id; 479 | 480 | // Proceed with the test. 481 | const res = await supertest(app) 482 | .delete(`/api/admin/users/${userId}`) 483 | .set("Accept", "application/json") 484 | .set("Authorization", `Bearer ${adminUser.access_token}`) // Uncomment this line to test with JWT in a future. Ensure the JWT has admin role. 485 | .send(); 486 | expect(res.status).toEqual(200); 487 | expect(res.body).toHaveProperty("message"); 488 | expect(res.body.message).toEqual("User deleted successfully"); 489 | }); 490 | }); 491 | 492 | describe("PATCH /users/markverified/:id", () => { 493 | it("should not mark verified if user is not found, i.e. invalid ID", async () => { 494 | const res = await supertest(app) 495 | .patch(`/api/admin/users/markverified/${"6339cebe708e3a1198f1997b"}`) 496 | .set("Accept", "application/json") 497 | .set("Authorization", `Bearer ${adminUser.access_token}`) // Uncomment this line to test with JWT in a future. Ensure the JWT has admin role. 498 | .send(); 499 | expect(res.status).toEqual(404); 500 | expect(res.body).toHaveProperty("error"); 501 | expect(res.body.error).toEqual("User not found"); 502 | }); 503 | 504 | it("should mark the user verified if the user is found", async () => { 505 | // Generate a random user. 506 | const newUser = await createUser(); 507 | 508 | // Signup a new user to verify later. 509 | const signupRes = await supertest(app) 510 | .post("/api/auth/signup") 511 | .set("Accept", "application/json") 512 | .send({ 513 | ...newUser, 514 | cpassword: newUser.password, 515 | }); 516 | 517 | expect(signupRes.statusCode).toEqual(201); 518 | expect(signupRes.body).toHaveProperty("message"); 519 | expect(signupRes.body.message).toEqual("User created successfully"); 520 | 521 | // Internal Service Call to fetch ID of the user. 522 | const userDocument = await findUserByEmailService(newUser.email); 523 | const userId = userDocument._id; 524 | 525 | // Proceed with the test. 526 | const res = await supertest(app) 527 | .patch(`/api/admin/users/markverified/${userId}`) 528 | .set("Accept", "application/json") 529 | .set("Authorization", `Bearer ${adminUser.access_token}`) 530 | .send(); 531 | expect(res.status).toEqual(200); 532 | expect(res.body).toHaveProperty("message"); 533 | expect(res.body.message).toEqual("User verified successfully"); 534 | }); 535 | }); 536 | 537 | describe("PATCH /users/:id", () => { 538 | it("should not update if user is not found, i.e. invalid ID", async () => { 539 | const res = await supertest(app) 540 | .patch(`/api/users/${"6339cebe708e3a1198f1997b"}`) 541 | .set("Accept", "application/json") 542 | .set("Authorization", `Bearer ${adminUser.access_token}`) 543 | .send({ 544 | role: "admin", 545 | }); 546 | expect(res.status).toEqual(404); 547 | expect(res.body).toHaveProperty("error"); 548 | expect(res.body.error).toEqual("User not found"); 549 | }); 550 | 551 | it("should not update if the username is already taken", async () => { 552 | // Generate a random user. 553 | const newUser = await createUser(); 554 | 555 | // Signup a new user to update later. 556 | const signupRes = await supertest(app) 557 | .post("/api/auth/signup") 558 | .set("Accept", "application/json") 559 | .send({ 560 | ...newUser, 561 | cpassword: newUser.password, 562 | }); 563 | 564 | expect(signupRes.statusCode).toEqual(201); 565 | expect(signupRes.body).toHaveProperty("message"); 566 | expect(signupRes.body.message).toEqual("User created successfully"); 567 | 568 | // Internal Service Call to fetch ID of the user. 569 | const userDocument = await findUserByEmailService(newUser.email); 570 | const userId = userDocument._id; 571 | 572 | const verifyRes = await supertest(app) 573 | .patch(`/api/admin/users/markverified/${userId}`) 574 | .set("Accept", "application/json") 575 | .set("Authorization", `Bearer ${adminUser.access_token}`) 576 | .send(); 577 | expect(verifyRes.status).toEqual(200); 578 | expect(verifyRes.body).toHaveProperty("message"); 579 | expect(verifyRes.body.message).toEqual("User verified successfully"); 580 | 581 | const loginRes = await supertest(app) 582 | .post("/api/auth/login") 583 | .set("Accept", "application/json") 584 | .send({ 585 | email: newUser.email, 586 | password: newUser.password, 587 | }); 588 | 589 | expect(loginRes.statusCode).toEqual(200); 590 | expect(loginRes.body).toHaveProperty("access_token"); 591 | expect(loginRes.body).toHaveProperty("refresh_token"); 592 | 593 | newUser.access_token = loginRes.body.access_token; 594 | newUser.refresh_token = loginRes.body.refresh_token; 595 | 596 | // Proceed with the test. 597 | const res = await supertest(app) 598 | .patch(`/api/users/${userId}`) 599 | .set("Accept", "application/json") 600 | .set("Authorization", `Bearer ${newUser.access_token}`) 601 | .send({ 602 | username: user.username, 603 | }); 604 | expect(res.status).toEqual(409); 605 | expect(res.body).toHaveProperty("error"); 606 | expect(res.body.error).toEqual("Username is already taken"); 607 | }); 608 | 609 | it("should update the user if the user is found", async () => { 610 | // Generate a random user. 611 | const newUser = await createUser(); 612 | 613 | // Signup a new user to update later. 614 | const signupRes = await supertest(app) 615 | .post("/api/auth/signup") 616 | .set("Accept", "application/json") 617 | .send({ 618 | ...newUser, 619 | cpassword: newUser.password, 620 | }); 621 | 622 | expect(signupRes.statusCode).toEqual(201); 623 | expect(signupRes.body).toHaveProperty("message"); 624 | expect(signupRes.body.message).toEqual("User created successfully"); 625 | 626 | // Internal Service Call to fetch ID of the user. 627 | let userDocument = await findUserByEmailService(newUser.email); 628 | const userId = userDocument._id; 629 | 630 | const verifyRes = await supertest(app) 631 | .patch(`/api/admin/users/markverified/${userId}`) 632 | .set("Accept", "application/json") 633 | .set("Authorization", `Bearer ${adminUser.access_token}`) 634 | .send(); 635 | expect(verifyRes.status).toEqual(200); 636 | expect(verifyRes.body).toHaveProperty("message"); 637 | expect(verifyRes.body.message).toEqual("User verified successfully"); 638 | 639 | const loginRes = await supertest(app) 640 | .post("/api/auth/login") 641 | .set("Accept", "application/json") 642 | .send({ 643 | email: newUser.email, 644 | password: newUser.password, 645 | }); 646 | 647 | expect(loginRes.statusCode).toEqual(200); 648 | expect(loginRes.body).toHaveProperty("access_token"); 649 | expect(loginRes.body).toHaveProperty("refresh_token"); 650 | 651 | newUser.access_token = loginRes.body.access_token; 652 | newUser.refresh_token = loginRes.body.refresh_token; 653 | 654 | // Proceed with the test. 655 | const res = await supertest(app) 656 | .patch(`/api/users/${userId}`) 657 | .set("Accept", "application/json") 658 | .set("Authorization", `Bearer ${newUser.access_token}`) 659 | .send({ 660 | username: "newUsername", 661 | }); 662 | expect(res.status).toEqual(200); 663 | expect(res.body).toHaveProperty("message"); 664 | expect(res.body.message).toEqual("User updated successfully"); 665 | 666 | // Internal Service Call to fetch the updfated user. 667 | userDocument = await findUserByEmailService(newUser.email); 668 | }); 669 | }); 670 | }); 671 | 672 | describe("/admin", () => { 673 | describe("PATCH /admin/users/markadmin/:id", () => { 674 | it("should not mark admin if user is not found, i.e. invalid ID", async () => { 675 | const res = await supertest(app) 676 | .patch(`/api/admin/users/markadmin/${"6339cebe708e3a1198f1997b"}`) 677 | .set("Accept", "application/json") 678 | .set("Authorization", `Bearer ${adminUser.access_token}`); 679 | expect(res.status).toEqual(404); 680 | expect(res.body).toHaveProperty("error"); 681 | expect(res.body.error).toEqual("User not found"); 682 | }); 683 | 684 | it("should not mark user as admin if the request is made by non-admin", async () => { 685 | // Generate a random user. 686 | const newUser = await createUser(); 687 | 688 | // Signup a new user to update later. 689 | const signupRes = await supertest(app) 690 | .post("/api/auth/signup") 691 | .set("Accept", "application/json") 692 | .send({ 693 | ...newUser, 694 | cpassword: newUser.password, 695 | }); 696 | 697 | expect(signupRes.statusCode).toEqual(201); 698 | expect(signupRes.body).toHaveProperty("message"); 699 | expect(signupRes.body.message).toEqual("User created successfully"); 700 | 701 | // Internal Service Call to fetch ID of the user. 702 | let userDocument = await findUserByEmailService(newUser.email); 703 | const userId = userDocument._id; 704 | 705 | const verifyRes = await supertest(app) 706 | .patch(`/api/admin/users/markverified/${userId}`) 707 | .set("Accept", "application/json") 708 | .set("Authorization", `Bearer ${adminUser.access_token}`) 709 | .send(); 710 | expect(verifyRes.status).toEqual(200); 711 | expect(verifyRes.body).toHaveProperty("message"); 712 | expect(verifyRes.body.message).toEqual("User verified successfully"); 713 | 714 | const loginRes = await supertest(app) 715 | .post("/api/auth/login") 716 | .set("Accept", "application/json") 717 | .send({ 718 | email: newUser.email, 719 | password: newUser.password, 720 | }); 721 | 722 | expect(loginRes.statusCode).toEqual(200); 723 | expect(loginRes.body).toHaveProperty("access_token"); 724 | expect(loginRes.body).toHaveProperty("refresh_token"); 725 | 726 | newUser.access_token = loginRes.body.access_token; 727 | newUser.refresh_token = loginRes.body.refresh_token; 728 | 729 | // Proceed with the test. 730 | const res = await supertest(app) 731 | .patch(`/api/admin/users/markadmin/${userId}`) 732 | .set("Accept", "application/json") 733 | .set("Authorization", `Bearer ${newUser.access_token}`); 734 | 735 | expect(res.status).toEqual(403); 736 | expect(res.body).toHaveProperty("error"); 737 | expect(res.body.error).toEqual("Insufficient rights"); 738 | 739 | // Internal Service Call to fetch the updated user. 740 | userDocument = await findUserByEmailService(newUser.email); 741 | }); 742 | 743 | it("should mark the user as admin if the user is found", async () => { 744 | // Generate a random user. 745 | const newUser = await createUser(); 746 | 747 | // Signup a new user to update later. 748 | const signupRes = await supertest(app) 749 | .post("/api/auth/signup") 750 | .set("Accept", "application/json") 751 | .send({ 752 | ...newUser, 753 | cpassword: newUser.password, 754 | }); 755 | 756 | expect(signupRes.statusCode).toEqual(201); 757 | expect(signupRes.body).toHaveProperty("message"); 758 | expect(signupRes.body.message).toEqual("User created successfully"); 759 | 760 | // Internal Service Call to fetch ID of the user. 761 | let userDocument = await findUserByEmailService(newUser.email); 762 | const userId = userDocument._id; 763 | 764 | const verifyRes = await supertest(app) 765 | .patch(`/api/admin/users/markverified/${userId}`) 766 | .set("Accept", "application/json") 767 | .set("Authorization", `Bearer ${adminUser.access_token}`) 768 | .send(); 769 | expect(verifyRes.status).toEqual(200); 770 | expect(verifyRes.body).toHaveProperty("message"); 771 | expect(verifyRes.body.message).toEqual("User verified successfully"); 772 | 773 | const loginRes = await supertest(app) 774 | .post("/api/auth/login") 775 | .set("Accept", "application/json") 776 | .send({ 777 | email: newUser.email, 778 | password: newUser.password, 779 | }); 780 | 781 | expect(loginRes.statusCode).toEqual(200); 782 | expect(loginRes.body).toHaveProperty("access_token"); 783 | expect(loginRes.body).toHaveProperty("refresh_token"); 784 | 785 | newUser.access_token = loginRes.body.access_token; 786 | newUser.refresh_token = loginRes.body.refresh_token; 787 | 788 | // Proceed with the test. 789 | const res = await supertest(app) 790 | .patch(`/api/admin/users/markadmin/${userId}`) 791 | .set("Accept", "application/json") 792 | .set("Authorization", `Bearer ${adminUser.access_token}`); 793 | 794 | expect(res.status).toEqual(200); 795 | expect(res.body).toHaveProperty("message"); 796 | expect(res.body.message).toEqual("User marked as admin successfully"); 797 | 798 | // Internal Service Call to fetch the updated user. 799 | userDocument = await findUserByEmailService(newUser.email); 800 | }); 801 | }); 802 | }); 803 | 804 | afterAll(async () => { 805 | console.log("Closing the current connection."); 806 | await mongoose.disconnect(); 807 | await mongoose.connection.close(); 808 | await mongod.stop(); 809 | }); 810 | }); 811 | 812 | /** 813 | * Helper function to generate a user object with random email, password and username. 814 | * @returns {Object} user 815 | */ 816 | 817 | async function createUser() { 818 | // Generate a random string for the email address 819 | const email = `${Math.random().toString(36).substring(4)}@multiemail.us`; 820 | // Generate a random string for the password 821 | const password = Math.random().toString(36).substring(4); 822 | // Generate a random string for the username 823 | const username = Math.random().toString(36).substring(4); 824 | 825 | // Update the user object with the generated email, password and username 826 | let user = { 827 | email: email, 828 | password: password, 829 | username: username, 830 | acceptedTermsAndConditions: true, 831 | receiveMarketingEmails: true, 832 | }; 833 | return user; 834 | } 835 | --------------------------------------------------------------------------------