├── .env.example ├── .github └── CODEOWNERS ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── docker-compose.yml ├── drizzle.config.ts ├── env.d.ts ├── eslint.config.js ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── src ├── controllers │ ├── admin-controllers.ts │ └── user-controllers.ts ├── middlewares │ └── auth.ts ├── migrate.ts ├── routes │ ├── admin-routes.ts │ ├── routes.ts │ └── user-routes.ts ├── schema │ └── user.ts ├── server.ts ├── services │ ├── admin-services.ts │ └── user-services.ts ├── templates │ ├── user-verified.tsx │ └── verification-email.tsx └── utils │ ├── create.ts │ ├── db.ts │ ├── email.ts │ ├── env.ts │ ├── errors.ts │ ├── hash.ts │ ├── jwt.ts │ └── logger.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | ENV=local 2 | 3 | PORT=3001 4 | API_BASE_URL=http://localhost:3001 5 | 6 | # postgres database url 7 | DB_URL=postgres://postgres:postgres@localhost:5432/postgres 8 | 9 | # ses access keys 10 | AWS_ACCESS_KEY=your_aws_access_key 11 | AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key 12 | AWS_REGION=us-west-2 13 | 14 | # email config 15 | FROM_NAME="Your Name" 16 | FROM_EMAIL=your_email@example.com 17 | 18 | # jwt secret 19 | JWT_SECRET=boilerplate@2024 20 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | ## PR approval owners 2 | * @leremitt/approvers -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | *.pem 17 | 18 | # debug 19 | .pnpm-debug.log* 20 | 21 | # local env files 22 | .env 23 | 24 | # typescript 25 | *.tsbuildinfo 26 | next-env.d.ts 27 | 28 | # editor config 29 | .idea/ 30 | 31 | # drizzle migrations 32 | /drizzle 33 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | // (remove this if your ESLint extension above v3.0.5) 4 | "eslint.experimental.useFlatConfig": true, 5 | "eslint.useFlatConfig": true, 6 | 7 | // Disable the default formatter, use eslint instead 8 | "prettier.enable": false, 9 | "editor.formatOnSave": false, 10 | 11 | // Auto fix 12 | "editor.codeActionsOnSave": { 13 | "source.fixAll.eslint": "explicit", 14 | "source.organizeImports": "never" 15 | }, 16 | 17 | // Silent the stylistic rules in you IDE, but still auto fix them 18 | "eslint.rules.customizations": [ 19 | { "rule": "style/*", "severity": "off" }, 20 | { "rule": "format/*", "severity": "off" }, 21 | { "rule": "*-indent", "severity": "off" }, 22 | { "rule": "*-spacing", "severity": "off" }, 23 | { "rule": "*-spaces", "severity": "off" }, 24 | { "rule": "*-order", "severity": "off" }, 25 | { "rule": "*-dangle", "severity": "off" }, 26 | { "rule": "*-newline", "severity": "off" }, 27 | { "rule": "*quotes", "severity": "off" }, 28 | { "rule": "*semi", "severity": "off" } 29 | ], 30 | 31 | // Enable eslint for all supported languages 32 | "eslint.validate": [ 33 | "javascript", 34 | "javascriptreact", 35 | "typescript", 36 | "typescriptreact", 37 | "vue", 38 | "html", 39 | "markdown", 40 | "json", 41 | "jsonc", 42 | "yaml", 43 | "toml", 44 | "xml", 45 | "gql", 46 | "graphql", 47 | "astro" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # techAaditi-backend 2 | 3 | ### Running the app 4 | 5 | Install docker desktop: 6 | 7 | [docker](https://docs.docker.com/engine/install/) 8 | 9 | Postgres DB docker: 10 | 11 | ```bash 12 | docker-compose up -d 13 | ``` 14 | ```bash 15 | wsl --install 16 | ``` 17 | 18 | Install pnpm: 19 | 20 | ```bash 21 | npm install -g pnpm 22 | ``` 23 | 24 | Install the dependencies: 25 | 26 | ```bash 27 | pnpm install 28 | ``` 29 | 30 | Drizzle migrate: 31 | 32 | ```bash 33 | pnpm run generate 34 | pnpm run migrate 35 | ``` 36 | 37 | 38 | Run the query to create test user: 39 | 40 | ```sql 41 | INSERT INTO public.users 42 | ("name", email, is_admin, "password", is_verified, salt, code) 43 | VALUES('Test', 'test@gmail.com', true, '$argon2id$v=19$m=65536,t=3,p=4$5cMjvKGsPYKzWlE0UZte82ZCkWnMBqg9kwpy5e8/9ug$ByQAhQLZy24I7iZvAepkUlt2tAYXe2233KXI1ikynig', true, 'e5c323bca1ac3d82b35a5134519b5ef366429169cc06a83d930a72e5ef3ff6e8', '25dbc6b03e23210a5c01d15573fdb1da26e50d2eeb10d67ccd7ba77c955c723b') 44 | ON CONFLICT (email) DO NOTHING; 45 | ``` 46 | 47 | Run the development server: 48 | 49 | ```bash 50 | pnpm dev 51 | ``` 52 | 53 | Test Credentials: 54 | 55 | test@gmail.com 56 | Test@123 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | db: 4 | image: postgres:16.3 5 | restart: always 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | ports: 10 | - "5432:5432" 11 | volumes: 12 | - db:/var/lib/postgresql/data 13 | healthcheck: 14 | test: ["CMD", "pg_isready", "-U", "postgres", "-d", "postgres", "-h", "localhost"] 15 | interval: 10s 16 | timeout: 5s 17 | retries: 5 18 | volumes: 19 | db: 20 | driver: local 21 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import type { Config } from 'drizzle-kit'; 3 | 4 | export default { 5 | schema: './src/schema/*', 6 | out: './drizzle', 7 | driver: 'pg', 8 | dbCredentials: { 9 | connectionString: process.env.DB_URL, 10 | }, 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from '@/utils/env'; 2 | 3 | declare global { 4 | namespace NodeJS { 5 | interface ProcessEnv extends Env {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@antfu/eslint-config'; 2 | 3 | export default eslint({ 4 | stylistic: { 5 | semi: true, 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "license": "MIT", 4 | "engines": { 5 | "node": ">=20.11.0" 6 | }, 7 | "scripts": { 8 | "build": "esbuild src/server.ts --bundle --outfile=build/server.js --platform=node --format=esm --packages=external", 9 | "start": "node build/server.js", 10 | "dev": "tsx watch src/server.ts", 11 | "generate": "drizzle-kit generate:pg", 12 | "migrate": "tsx src/migrate.ts", 13 | "lint": "eslint .", 14 | "lint:fix": "eslint . --fix" 15 | }, 16 | "dependencies": { 17 | "@aws-sdk/client-ses": "^3.504.0", 18 | "@react-email/components": "^0.0.14", 19 | "@react-email/render": "^0.0.12", 20 | "argon2": "^0.31.2", 21 | "consola": "^3.2.3", 22 | "cors": "^2.8.5", 23 | "dotenv": "^16.4.1", 24 | "drizzle-orm": "^0.30.9", 25 | "drizzle-zod": "^0.5.1", 26 | "express": "^4.19.2", 27 | "express-rate-limit": "^7.1.5", 28 | "jsonwebtoken": "^9.0.2", 29 | "pino": "^9.1.0", 30 | "postgres": "^3.4.3", 31 | "react": "^18.2.0", 32 | "request-ip": "^3.3.0", 33 | "zod": "^3.22.4" 34 | }, 35 | "devDependencies": { 36 | "@antfu/eslint-config": "^2.18.0", 37 | "@stylistic/eslint-plugin": "^2.1.0", 38 | "@types/cors": "^2.8.17", 39 | "@types/express": "^4.17.21", 40 | "@types/jsonwebtoken": "^9.0.5", 41 | "@types/node": "^20.11.16", 42 | "@types/react": "^18.2.53", 43 | "@types/request-ip": "^0.0.41", 44 | "drizzle-kit": "^0.20.18", 45 | "esbuild": "^0.20.0", 46 | "eslint": "^8.57.0", 47 | "pg": "^8.11.3", 48 | "tsx": "^4.7.0", 49 | "typescript": "^5.3.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/controllers/admin-controllers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deleteAllUnverifiedUsers, 3 | getAllUsers, 4 | getAllVerifiedUsers, 5 | } from '@/services/admin-services'; 6 | import { createHandler } from '@/utils/create'; 7 | 8 | export const handleGetAllVerifiedUsers = createHandler(async (_req, res) => { 9 | const users = await getAllVerifiedUsers(); 10 | res.status(200).json({ 11 | users, 12 | }); 13 | }); 14 | 15 | export const handleGetAllUsers = createHandler(async (_req, res) => { 16 | const users = await getAllUsers(); 17 | res.status(200).json({ 18 | users, 19 | }); 20 | }); 21 | 22 | export const handleDeleteAllUnverifiedUsers = createHandler(async (_req, res) => { 23 | const unverfiedUsersCount = await deleteAllUnverifiedUsers(); 24 | res.status(200).json({ 25 | message: `${unverfiedUsersCount} unverified users deleted successfully`, 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/controllers/user-controllers.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { Buffer } from 'node:buffer'; 3 | // import { render } from '@react-email/render'; 4 | import argon2 from 'argon2'; 5 | import { 6 | type User, 7 | deleteUserSchema, 8 | loginSchema, 9 | newUserSchema, 10 | updateUserSchema, 11 | verifyUserSchema, 12 | } from '@/schema/user'; 13 | import { 14 | addUser, 15 | deleteUser, 16 | getUserByEmail, 17 | updateUser, 18 | verifyUser, 19 | } from '@/services/user-services'; 20 | // import { UserVerified } from '@/templates/user-verified'; 21 | import { createHandler } from '@/utils/create'; 22 | // import { sendVerificationEmail } from '@/utils/email'; 23 | import { BackendError, getStatusFromErrorCode } from '@/utils/errors'; 24 | import generateToken from '@/utils/jwt'; 25 | 26 | export const handleUserLogin = createHandler(loginSchema, async (req, res) => { 27 | const { email, password } = req.body; 28 | const user = await getUserByEmail(email); 29 | 30 | if (!user) 31 | throw new BackendError('USER_NOT_FOUND'); 32 | 33 | const matchPassword = await argon2.verify(user.password, password, { 34 | salt: Buffer.from(user.salt, 'hex'), 35 | }); 36 | if (!matchPassword) 37 | throw new BackendError('INVALID_PASSWORD'); 38 | 39 | const token = generateToken(user.id); 40 | res.status(200).json({ token }); 41 | }); 42 | 43 | export const handleAddUser = createHandler(newUserSchema, async (req, res) => { 44 | const user = req.body; 45 | 46 | const existingUser = await getUserByEmail(user.email); 47 | 48 | if (existingUser) { 49 | throw new BackendError('CONFLICT', { 50 | message: 'User already exists', 51 | }); 52 | } 53 | 54 | const { user: addedUser, code } = await addUser(user); 55 | 56 | // const status = await sendVerificationEmail( 57 | // process.env.API_BASE_URL, 58 | // addedUser.name, 59 | // addedUser.email, 60 | // code, 61 | // ); 62 | 63 | // if (status !== 200) { 64 | // await deleteUser(addedUser.email); 65 | // throw new BackendError('INTERNAL_ERROR', { 66 | // message: 'Failed to signup user', 67 | // }); 68 | // } 69 | 70 | res.status(201).json(addedUser); 71 | }); 72 | 73 | // export const handleVerifyUser = createHandler(verifyUserSchema, async (req, res) => { 74 | // try { 75 | // const { email, code } = req.query; 76 | 77 | // await verifyUser(email, code); 78 | // const template = render( 79 | // UserVerified({ status: 'verified', message: 'User verified successfully' }), 80 | // ); 81 | // res.status(200).send(template); 82 | // } 83 | // catch (err) { 84 | // if (err instanceof BackendError) { 85 | // const template = render( 86 | // UserVerified({ 87 | // status: 'invalid', 88 | // message: err.message, 89 | // error: 'Invalid Request', 90 | // }), 91 | // ); 92 | // res.status(getStatusFromErrorCode(err.code)).send(template); 93 | // return; 94 | // } 95 | // throw err; 96 | // } 97 | // }); 98 | 99 | export const handleDeleteUser = createHandler(deleteUserSchema, async (req, res) => { 100 | const { email } = req.body; 101 | 102 | const { user } = res.locals as { user: User }; 103 | 104 | if (user.email !== email && !user.isAdmin) { 105 | throw new BackendError('UNAUTHORIZED', { 106 | message: 'You are not authorized to delete this user', 107 | }); 108 | } 109 | 110 | const deletedUser = await deleteUser(email); 111 | 112 | res.status(200).json({ 113 | user: deletedUser, 114 | }); 115 | }); 116 | 117 | export const handleGetUser = createHandler(async (_req, res) => { 118 | const { user } = res.locals as { user: User }; 119 | 120 | res.status(200).json({ 121 | user: { 122 | id: user.id, 123 | name: user.name, 124 | email: user.email, 125 | isAdmin: user.isAdmin, 126 | isVerified: user.isVerified, 127 | createdAt: user.createdAt, 128 | }, 129 | }); 130 | }); 131 | 132 | export const handleUpdateUser = createHandler(updateUserSchema, async (req, res) => { 133 | const { user } = res.locals as { user: User }; 134 | 135 | const { name, password, email } = req.body; 136 | 137 | const updatedUser = await updateUser(user, { name, password, email }); 138 | 139 | res.status(200).json({ 140 | user: updatedUser, 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import { getUserByUserId } from '@/services/user-services'; 2 | import { createHandler } from '@/utils/create'; 3 | import { BackendError } from '@/utils/errors'; 4 | import { verifyToken } from '@/utils/jwt'; 5 | 6 | export function authenticate({ verifyAdmin } = { 7 | verifyAdmin: false, 8 | }) { 9 | return createHandler(async (req, res, next) => { 10 | const { authorization } = req.headers; 11 | 12 | if (!authorization) { 13 | throw new BackendError('UNAUTHORIZED', { 14 | message: 'Authorization header not found', 15 | }); 16 | } 17 | 18 | const token = authorization.split(' ')[1]; 19 | 20 | if (!token) { 21 | throw new BackendError('UNAUTHORIZED', { 22 | message: 'Token not found', 23 | }); 24 | } 25 | 26 | const { userId } = verifyToken(token); 27 | 28 | const user = await getUserByUserId(userId); 29 | 30 | if (!user) 31 | throw new BackendError('USER_NOT_FOUND'); 32 | 33 | if (!user.isVerified) { 34 | throw new BackendError('UNAUTHORIZED', { 35 | message: 'User not verified', 36 | }); 37 | } 38 | 39 | if (verifyAdmin && !user.isAdmin) { 40 | throw new BackendError('UNAUTHORIZED', { 41 | message: 'User not authorized', 42 | }); 43 | } 44 | 45 | res.locals.user = user; 46 | next(); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /src/migrate.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { migrate } from 'drizzle-orm/postgres-js/migrator'; 3 | import postgres from 'postgres'; 4 | import { drizzle } from 'drizzle-orm/postgres-js'; 5 | import drizzleConfig from '../drizzle.config'; 6 | import 'dotenv/config'; 7 | 8 | async function main() { 9 | const connection = postgres(process.env.DB_URL, { max: 1 }); 10 | 11 | // This will run migrations on the database, skipping the ones already applied 12 | await migrate(drizzle(connection), { migrationsFolder: drizzleConfig.out }); 13 | 14 | // Don't forget to close the connection, otherwise the script will hang 15 | await connection.end(); 16 | } 17 | 18 | main(); 19 | -------------------------------------------------------------------------------- /src/routes/admin-routes.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'express'; 2 | import { 3 | handleDeleteAllUnverifiedUsers, 4 | handleGetAllUsers, 5 | handleGetAllVerifiedUsers, 6 | } from '@/controllers/admin-controllers'; 7 | import { authenticate } from '@/middlewares/auth'; 8 | import { createRouter } from '@/utils/create'; 9 | 10 | export default createRouter((router: Router) => { 11 | router.use( 12 | authenticate({ 13 | verifyAdmin: true, 14 | }), 15 | ); 16 | 17 | router.get('/all-users', handleGetAllUsers); 18 | router.get('/all-verfied-users', handleGetAllVerifiedUsers); 19 | router.delete('/remove-unverified-users', handleDeleteAllUnverifiedUsers); 20 | }); 21 | -------------------------------------------------------------------------------- /src/routes/routes.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'express'; 2 | import adminRoutes from '@/routes/admin-routes'; 3 | import userRoutes from '@/routes/user-routes'; 4 | import { createRouter } from '@/utils/create'; 5 | 6 | export default createRouter((router: Router) => { 7 | router.use('/admin', adminRoutes); 8 | router.use('/user', userRoutes); 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/user-routes.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'express'; 2 | import { 3 | handleAddUser, 4 | handleDeleteUser, 5 | handleGetUser, 6 | handleUpdateUser, 7 | handleUserLogin, 8 | // handleVerifyUser, 9 | } from '@/controllers/user-controllers'; 10 | import { authenticate } from '@/middlewares/auth'; 11 | import { createRouter } from '@/utils/create'; 12 | 13 | export default createRouter((router: Router) => { 14 | router.get('/', authenticate(), handleGetUser); 15 | // router.get('/verify', (req, res, next) => handleVerifyUser(req, res, next)); 16 | router.post('/create', handleAddUser); 17 | router.post('/login', handleUserLogin); 18 | router.post('/remove', authenticate(), handleDeleteUser); 19 | router.put('/update', authenticate(), handleUpdateUser); 20 | }); 21 | -------------------------------------------------------------------------------- /src/schema/user.ts: -------------------------------------------------------------------------------- 1 | import type { InferSelectModel } from 'drizzle-orm'; 2 | import { boolean, pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; 3 | import { createSelectSchema } from 'drizzle-zod'; 4 | import { z } from 'zod'; 5 | 6 | export const users = pgTable('users', { 7 | id: uuid('id').notNull().primaryKey().defaultRandom(), 8 | name: varchar('name', { length: 255 }).notNull(), 9 | email: text('email').notNull().unique(), 10 | isAdmin: boolean('is_admin').notNull().default(false), 11 | password: text('password').notNull(), 12 | isVerified: boolean('is_verified').notNull().default(false), 13 | salt: text('salt').notNull(), 14 | code: text('code').notNull(), 15 | createdAt: timestamp('created_at').notNull().defaultNow(), 16 | updatedAt: timestamp('updated_at').notNull().defaultNow(), 17 | }); 18 | 19 | export const selectUserSchema = createSelectSchema(users, { 20 | email: schema => 21 | schema.email.email().regex(/^([\w.%-]+@[a-z0-9.-]+\.[a-z]{2,6})*$/i), 22 | }); 23 | 24 | export const verifyUserSchema = z.object({ 25 | query: selectUserSchema.pick({ 26 | email: true, 27 | code: true, 28 | }), 29 | }); 30 | 31 | export const deleteUserSchema = z.object({ 32 | body: selectUserSchema.pick({ 33 | email: true, 34 | }), 35 | }); 36 | 37 | export const loginSchema = z.object({ 38 | body: selectUserSchema.pick({ 39 | email: true, 40 | password: true, 41 | }), 42 | }); 43 | 44 | export const addUserSchema = z.object({ 45 | body: selectUserSchema.pick({ 46 | name: true, 47 | email: true, 48 | password: true, 49 | }), 50 | }); 51 | 52 | export const updateUserSchema = z.object({ 53 | body: selectUserSchema 54 | .pick({ 55 | name: true, 56 | email: true, 57 | password: true, 58 | }) 59 | .partial(), 60 | }); 61 | 62 | export const newUserSchema = z.object({ 63 | body: selectUserSchema.pick({ 64 | name: true, 65 | email: true, 66 | password: true, 67 | }), 68 | }); 69 | 70 | export type User = InferSelectModel; 71 | export type NewUser = z.infer['body']; 72 | export type UpdateUser = z.infer['body']; 73 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import consola from 'consola'; 3 | import cors from 'cors'; 4 | import express from 'express'; 5 | import rateLimit from 'express-rate-limit'; 6 | import { mw as requestIp } from 'request-ip'; 7 | import { logger } from './utils/logger'; 8 | import { errorHandler, handle404Error } from '@/utils/errors'; 9 | import routes from '@/routes/routes'; 10 | 11 | import './utils/env'; 12 | 13 | 14 | const { PORT, ENV } = process.env; 15 | 16 | const app = express(); 17 | 18 | app.use(express.json()); 19 | app.use(cors()); 20 | app.use(requestIp()); 21 | app.use( 22 | rateLimit({ 23 | windowMs: 15 * 60 * 1000, 24 | max: 1000, 25 | handler: (req, res) => { 26 | consola.warn(`DDoS Attempt from ${req.ip}`); 27 | res.status(429).json({ 28 | error: 'Too many requests in a short time. Please try in a minute.', 29 | }); 30 | }, 31 | }), 32 | ); 33 | 34 | app.use(logger); 35 | 36 | app.get('/', (_req, res) => { 37 | 38 | res.json({ 39 | message: 'Welcome to the API!', 40 | 41 | }); 42 | }); 43 | 44 | app.get('/healthcheck', (_req, res) => { 45 | res.json({ 46 | message: 'Server is running', 47 | uptime: process.uptime(), 48 | timestamp: Date.now(), 49 | }); 50 | }); 51 | 52 | app.use('/api/v1', routes); 53 | 54 | app.all('*', handle404Error); 55 | app.use(errorHandler); 56 | 57 | app.listen(PORT, () => { 58 | consola.info(`Server running at http://localhost:${PORT}`); 59 | }); 60 | -------------------------------------------------------------------------------- /src/services/admin-services.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from 'drizzle-orm'; 2 | import { users } from '@/schema/user'; 3 | import { db } from '@/utils/db'; 4 | 5 | export async function getAllVerifiedUsers() { 6 | return await db 7 | .select({ 8 | id: users.id, 9 | name: users.name, 10 | email: users.email, 11 | isVerified: users.isVerified, 12 | isAdmin: users.isAdmin, 13 | createdAt: users.createdAt, 14 | }) 15 | .from(users) 16 | .where(and(eq(users.isVerified, true), eq(users.isAdmin, false))); 17 | } 18 | 19 | export async function getAllUsers() { 20 | return await db 21 | .select({ 22 | id: users.id, 23 | name: users.name, 24 | email: users.email, 25 | isVerified: users.isVerified, 26 | isAdmin: users.isAdmin, 27 | createdAt: users.createdAt, 28 | }) 29 | .from(users); 30 | } 31 | 32 | export async function deleteAllUnverifiedUsers() { 33 | const deletedUsers = await db 34 | .delete(users) 35 | .where(eq(users.isVerified, false)) 36 | .returning(); 37 | return deletedUsers.length; 38 | } 39 | -------------------------------------------------------------------------------- /src/services/user-services.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import crypto from 'node:crypto'; 3 | import argon2 from 'argon2'; 4 | import { eq } from 'drizzle-orm'; 5 | import { type NewUser, type UpdateUser, type User, users } from '@/schema/user'; 6 | import { db } from '@/utils/db'; 7 | import { sendVerificationEmail } from '@/utils/email'; 8 | import { BackendError } from '@/utils/errors'; 9 | import { sha256 } from '@/utils/hash'; 10 | 11 | export async function getUserByUserId(userId: string) { 12 | const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); 13 | return user; 14 | } 15 | 16 | export async function getUserByEmail(email: string) { 17 | const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1); 18 | return user; 19 | } 20 | 21 | export async function addUser(user: NewUser) { 22 | const { password, ...userDetails } = user; 23 | 24 | const salt = crypto.randomBytes(32); 25 | const code = crypto.randomBytes(32).toString('hex'); 26 | const hashedPassword = await argon2.hash(password, { 27 | salt, 28 | }); 29 | 30 | console.log({ ...userDetails, 31 | password: hashedPassword, 32 | salt: salt.toString('hex'), 33 | code,},"dhoiru0e") 34 | const [newUser] = await db 35 | .insert(users) 36 | .values({ 37 | ...userDetails, 38 | password: hashedPassword, 39 | salt: salt.toString('hex'), 40 | code, 41 | }) 42 | .returning({ 43 | id: users.id, 44 | name: users.name, 45 | email: users.email, 46 | code: users.code, 47 | isVerified: users.isVerified, 48 | isAdmin: users.isAdmin, 49 | }); 50 | 51 | if (!newUser) { 52 | throw new BackendError('INTERNAL_ERROR', { 53 | message: 'Failed to add user', 54 | }); 55 | } 56 | 57 | return { user: newUser, code }; 58 | } 59 | 60 | export async function verifyUser(email: string, code: string) { 61 | const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1); 62 | 63 | if (!user) 64 | throw new BackendError('USER_NOT_FOUND'); 65 | 66 | if (user.isVerified) { 67 | throw new BackendError('CONFLICT', { 68 | message: 'User already verified', 69 | }); 70 | } 71 | 72 | const isVerified = sha256.verify(code, user.code); 73 | 74 | if (!isVerified) { 75 | throw new BackendError('UNAUTHORIZED', { 76 | message: 'Invalid verification code', 77 | }); 78 | } 79 | 80 | const [updatedUser] = await db 81 | .update(users) 82 | .set({ isVerified }) 83 | .where(eq(users.email, email)) 84 | .returning({ id: users.id }); 85 | 86 | if (!updatedUser) { 87 | throw new BackendError('INTERNAL_ERROR', { 88 | message: 'Failed to verify user', 89 | }); 90 | } 91 | } 92 | 93 | export async function deleteUser(email: string) { 94 | const user = await getUserByEmail(email); 95 | 96 | if (!user) 97 | throw new BackendError('USER_NOT_FOUND'); 98 | 99 | const [deletedUser] = await db.delete(users).where(eq(users.email, email)).returning({ 100 | id: users.id, 101 | name: users.name, 102 | email: users.email, 103 | }); 104 | 105 | return deletedUser; 106 | } 107 | 108 | export async function updateUser(user: User, { name, email, password }: UpdateUser) { 109 | let code: string | undefined; 110 | let hashedCode: string | undefined; 111 | 112 | if (email) { 113 | const user = await getUserByEmail(email); 114 | 115 | if (user) { 116 | throw new BackendError('CONFLICT', { 117 | message: 'Email already in use', 118 | details: { email }, 119 | }); 120 | } 121 | 122 | code = crypto.randomBytes(32).toString('hex'); 123 | hashedCode = sha256.hash(code); 124 | } 125 | 126 | const [updatedUser] = await db 127 | .update(users) 128 | .set({ 129 | name, 130 | password, 131 | email, 132 | code: hashedCode, 133 | isVerified: hashedCode ? false : user.isVerified, 134 | }) 135 | .where(eq(users.email, user.email)) 136 | .returning({ 137 | id: users.id, 138 | name: users.name, 139 | email: users.email, 140 | isAdmin: users.isAdmin, 141 | isVerified: users.isVerified, 142 | createdAt: users.createdAt, 143 | }); 144 | 145 | if (!updatedUser) { 146 | throw new BackendError('USER_NOT_FOUND', { 147 | message: 'User could not be updated', 148 | }); 149 | } 150 | 151 | if (email && code) { 152 | const { API_BASE_URL } = process.env; 153 | const status = await sendVerificationEmail( 154 | API_BASE_URL, 155 | updatedUser.name, 156 | updatedUser.email, 157 | code, 158 | ); 159 | 160 | if (status !== 200) { 161 | await db 162 | .update(users) 163 | .set({ email: user.email, isVerified: user.isVerified }) 164 | .where(eq(users.email, updatedUser.email)) 165 | .returning(); 166 | throw new BackendError('BAD_REQUEST', { 167 | message: 'Email could not be updated', 168 | }); 169 | } 170 | } 171 | 172 | return updatedUser; 173 | } 174 | -------------------------------------------------------------------------------- /src/templates/user-verified.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Container, 4 | Font, 5 | Head, 6 | Heading, 7 | Html, 8 | Preview, 9 | Section, 10 | Tailwind, 11 | Text, 12 | } from '@react-email/components'; 13 | import * as React from 'react'; 14 | 15 | interface UserVerifiedProps { 16 | status: 'verified' | 'invalid'; 17 | message: string; 18 | error?: string; 19 | } 20 | 21 | export function UserVerified({ status, message, error }: UserVerifiedProps) { 22 | if (!error) 23 | error = 'Unknown error'; 24 | 25 | return ( 26 | 27 | 28 | 29 | {status === 'verified' 30 | ? 'Email verified!' 31 | : status === 'invalid' 32 | ? error 33 | : 'Unknown error'} 34 | 35 | 43 | 44 | 45 | {status === 'verified' 46 | ? 'Email verified!' 47 | : status === 'invalid' 48 | ? error 49 | : 'Unknown error'} 50 | 51 | 52 | 53 | 56 |
57 | 58 | {status === 'verified' 59 | ? 'Email verified!' 60 | : status === 'invalid' 61 | ? error 62 | : 'Unknown error'} 63 | 64 | {message} 65 |
66 |
67 | 68 |
69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/templates/verification-email.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Button, 4 | Container, 5 | Font, 6 | Head, 7 | Heading, 8 | Html, 9 | Preview, 10 | Section, 11 | Tailwind, 12 | Text, 13 | } from '@react-email/components'; 14 | import * as React from 'react'; 15 | 16 | interface VerificationEmailProps { 17 | baseUrl: string; 18 | name: string; 19 | email: string; 20 | code: string; 21 | } 22 | 23 | export function VerificationEmail({ 24 | baseUrl, 25 | name, 26 | email, 27 | code, 28 | }: VerificationEmailProps) { 29 | const url = `${baseUrl}/user/verify?email=${email}&code=${code}`; 30 | 31 | return ( 32 | 33 | 34 | Verify your email! 35 | 43 | 44 | Verify your email address 45 | 46 | 47 | 48 |
49 | Verify your email! 50 | 51 | Hello 52 | {name} 53 | !, 54 | 55 | 56 | Thank you for signing up! Please click the button below to verify your 57 | email address. 58 | 59 | 65 |
66 |
67 | 68 | If you did not request this email, please ignore it. 69 | 70 |
71 |
72 | 73 |
74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/create.ts: -------------------------------------------------------------------------------- 1 | import { type NextFunction, type Request, type Response, Router } from 'express'; 2 | import type { z } from 'zod'; 3 | 4 | export function createRouter(callback: (router: Router) => void) { 5 | const router = Router(); 6 | callback(router); 7 | return router; 8 | } 9 | 10 | export function createHandler( 11 | schema: T, 12 | handler: ( 13 | req: Omit> & z.output, 14 | res: Response, 15 | next: NextFunction 16 | ) => void | Promise 17 | ): (req: Request, res: Response, next: NextFunction) => Promise; 18 | 19 | export function createHandler( 20 | handler: (req: Request, res: Response, next: NextFunction) => void | Promise 21 | ): (req: Request, res: Response, next: NextFunction) => Promise; 22 | 23 | export function createHandler( 24 | schemaOrHandler: 25 | | T 26 | | ((req: Request, res: Response, next: NextFunction) => void | Promise), 27 | handler?: ( 28 | req: Omit> & z.output, 29 | res: Response, 30 | next: NextFunction 31 | ) => void | Promise, 32 | ) { 33 | return async (req: Request, res: Response, next: NextFunction) => { 34 | try { 35 | if (handler) { 36 | const schema = schemaOrHandler as T; 37 | schema.parse(req); 38 | await handler(req, res, next); 39 | } 40 | else { 41 | const handler = schemaOrHandler as ( 42 | req: Request, 43 | res: Response, 44 | next: NextFunction 45 | ) => void | Promise; 46 | await handler(req, res, next); 47 | } 48 | } 49 | catch (error) { 50 | next(error); 51 | } 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/db.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { drizzle } from 'drizzle-orm/postgres-js'; 3 | import postgres from 'postgres'; 4 | import '../utils/env'; 5 | export const db = drizzle(postgres(process.env.DB_URL)); 6 | -------------------------------------------------------------------------------- /src/utils/email.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'; 3 | import { render } from '@react-email/render'; 4 | import { VerificationEmail } from '@/templates/verification-email'; 5 | 6 | let client: SESClient; 7 | 8 | export function getEmailClient() { 9 | if (!client) { 10 | client = new SESClient({ 11 | credentials: { 12 | accessKeyId: process.env.AWS_ACCESS_KEY, 13 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 14 | }, 15 | region: process.env.AWS_REGION, 16 | }); 17 | } 18 | 19 | return client; 20 | } 21 | 22 | export async function sendVerificationEmail(baseUrl: string, name: string, email: string, code: string) { 23 | try { 24 | const client = getEmailClient(); 25 | const { FROM_NAME, FROM_EMAIL } = process.env; 26 | 27 | const emailHtml = render(VerificationEmail({ baseUrl, name, email, code })); 28 | 29 | const params = { 30 | Source: `${FROM_NAME} <${FROM_EMAIL}>`, 31 | Destination: { 32 | ToAddresses: [email], 33 | }, 34 | Message: { 35 | Subject: { 36 | Charset: 'UTF-8', 37 | Data: 'Verify your email!', 38 | }, 39 | Body: { 40 | Html: { 41 | Charset: 'UTF-8', 42 | Data: emailHtml, 43 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | const command = new SendEmailCommand(params); 49 | 50 | const res = await client.send(command); 51 | return res.$metadata.httpStatusCode; 52 | } 53 | catch (_err) { 54 | return 500; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { ZodError, z } from 'zod'; 3 | import 'dotenv/config'; 4 | 5 | const configSchema = z.object({ 6 | PORT: z 7 | .string() 8 | .regex(/^\d{4,5}$/) 9 | .optional() 10 | .default('3000'), 11 | API_BASE_URL: z.string().url().default('/api'), 12 | DB_URL: z 13 | .string() 14 | .url() 15 | .refine( 16 | url => url.startsWith('postgres://') || url.startsWith('postgresql://'), 17 | 'DB_URL must be a valid postgresql url', 18 | ), 19 | // FROM_NAME: z.string().default('Verify'), 20 | // FROM_EMAIL: z.string().email(), 21 | // AWS_ACCESS_KEY: z.string(), 22 | // AWS_SECRET_ACCESS_KEY: z.string(), 23 | // AWS_REGION: z.string(), 24 | // JWT_SECRET: z.string(), 25 | }); 26 | 27 | try { 28 | configSchema.parse(process.env); 29 | } 30 | catch (error) { 31 | if (error instanceof ZodError) 32 | console.error(error.errors); 33 | 34 | process.exit(1); 35 | } 36 | 37 | export type Env = z.infer; 38 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import consola from 'consola'; 2 | import type { NextFunction, Request, Response } from 'express'; 3 | import postgres from 'postgres'; 4 | import { ZodError } from 'zod'; 5 | 6 | type HttpErrorCode = 7 | | 'BAD_REQUEST' 8 | | 'UNAUTHORIZED' 9 | | 'NOT_FOUND' 10 | | 'METHOD_NOT_ALLOWED' 11 | | 'NOT_ACCEPTABLE' 12 | | 'REQUEST_TIMEOUT' 13 | | 'CONFLICT' 14 | | 'GONE' 15 | | 'LENGTH_REQUIRED' 16 | | 'PRECONDITION_FAILED' 17 | | 'PAYLOAD_TOO_LARGE' 18 | | 'URI_TOO_LONG' 19 | | 'UNSUPPORTED_MEDIA_TYPE' 20 | | 'RANGE_NOT_SATISFIABLE' 21 | | 'EXPECTATION_FAILED' 22 | | 'TEAPOT'; 23 | 24 | type BackendErrorCode = 'VALIDATION_ERROR' | 'USER_NOT_FOUND' | 'INVALID_PASSWORD'; 25 | 26 | type ErrorCode = HttpErrorCode | BackendErrorCode | 'INTERNAL_ERROR'; 27 | 28 | export function getStatusFromErrorCode(code: ErrorCode): number { 29 | switch (code) { 30 | case 'BAD_REQUEST': 31 | case 'VALIDATION_ERROR': 32 | return 400; 33 | case 'UNAUTHORIZED': 34 | case 'INVALID_PASSWORD': 35 | return 401; 36 | case 'NOT_FOUND': 37 | case 'USER_NOT_FOUND': 38 | return 404; 39 | case 'METHOD_NOT_ALLOWED': 40 | return 405; 41 | case 'NOT_ACCEPTABLE': 42 | return 406; 43 | case 'REQUEST_TIMEOUT': 44 | return 408; 45 | case 'CONFLICT': 46 | return 409; 47 | case 'GONE': 48 | return 410; 49 | case 'LENGTH_REQUIRED': 50 | return 411; 51 | case 'PRECONDITION_FAILED': 52 | return 412; 53 | case 'PAYLOAD_TOO_LARGE': 54 | return 413; 55 | case 'URI_TOO_LONG': 56 | return 414; 57 | case 'UNSUPPORTED_MEDIA_TYPE': 58 | return 415; 59 | case 'RANGE_NOT_SATISFIABLE': 60 | return 416; 61 | case 'EXPECTATION_FAILED': 62 | return 417; 63 | case 'TEAPOT': 64 | return 418; // I'm a teapot 65 | case 'INTERNAL_ERROR': 66 | return 500; 67 | default: 68 | return 500; 69 | } 70 | } 71 | 72 | export function getMessageFromErrorCode(code: ErrorCode): string { 73 | switch (code) { 74 | case 'BAD_REQUEST': 75 | return 'The request is invalid.'; 76 | case 'VALIDATION_ERROR': 77 | return 'The request contains invalid or missing fields.'; 78 | case 'UNAUTHORIZED': 79 | return 'You are not authorized to access this resource.'; 80 | case 'NOT_FOUND': 81 | return 'The requested resource was not found.'; 82 | case 'USER_NOT_FOUND': 83 | return 'The user was not found.'; 84 | case 'INTERNAL_ERROR': 85 | return 'An internal server error occurred.'; 86 | case 'CONFLICT': 87 | return 'The request conflicts with the current state of the server.'; 88 | case 'INVALID_PASSWORD': 89 | return 'The password is incorrect.'; 90 | default: 91 | return 'An internal server error occurred.'; 92 | } 93 | } 94 | 95 | export function handleValidationError(err: ZodError): { 96 | invalidFields: string[]; 97 | requiredFields: string[]; 98 | } { 99 | const invalidFields = []; 100 | const requiredFields = []; 101 | 102 | for (const error of err.errors) { 103 | if (error.code === 'invalid_type') 104 | invalidFields.push(error.path.join('.')); 105 | else if (error.message === 'Required') 106 | requiredFields.push(error.path.join('.')); 107 | } 108 | 109 | return { 110 | invalidFields, 111 | requiredFields, 112 | }; 113 | } 114 | 115 | export class BackendError extends Error { 116 | code: ErrorCode; 117 | details?: unknown; 118 | constructor( 119 | code: ErrorCode, 120 | { 121 | message, 122 | details, 123 | }: { 124 | message?: string; 125 | details?: unknown; 126 | } = {}, 127 | ) { 128 | super(message ?? getMessageFromErrorCode(code)); 129 | this.code = code; 130 | this.details = details; 131 | } 132 | } 133 | 134 | export function errorHandler(error: unknown, req: Request, res: Response<{ 135 | code: ErrorCode; 136 | message: string; 137 | details?: unknown; 138 | }>, _next: NextFunction) { 139 | let statusCode = 500; 140 | let code: ErrorCode | undefined; 141 | let message: string | undefined; 142 | let details: unknown | undefined; 143 | 144 | const ip = req.ip; 145 | const url = req.originalUrl; 146 | const method = req.method; 147 | 148 | if (error instanceof BackendError) { 149 | message = error.message; 150 | code = error.code; 151 | details = error.details; 152 | statusCode = getStatusFromErrorCode(code); 153 | } 154 | 155 | if (error instanceof postgres.PostgresError) { 156 | code = 'INTERNAL_ERROR'; 157 | message = 'The DB crashed maybe because they dont like you :p'; 158 | statusCode = getStatusFromErrorCode(code); 159 | details = error; 160 | } 161 | 162 | if (error instanceof ZodError) { 163 | code = 'VALIDATION_ERROR'; 164 | message = getMessageFromErrorCode(code); 165 | details = handleValidationError(error); 166 | statusCode = getStatusFromErrorCode(code); 167 | } 168 | 169 | if ((error as { code: string }).code === 'ECONNREFUSED') { 170 | code = 'INTERNAL_ERROR'; 171 | message = 'The DB crashed maybe because they dont like you :p'; 172 | details = error; 173 | } 174 | 175 | code = code ?? 'INTERNAL_ERROR'; 176 | message = message ?? getMessageFromErrorCode(code); 177 | details = details ?? error; 178 | 179 | consola.error(`${ip} [${method}] ${url} ${code} - ${message}`); 180 | 181 | res.status(statusCode).json({ 182 | code, 183 | message, 184 | details, 185 | }); 186 | } 187 | 188 | export function handle404Error(_req: Request, res: Response) { 189 | const code: ErrorCode = 'NOT_FOUND'; 190 | res.status(getStatusFromErrorCode(code)).json({ 191 | code, 192 | message: 'Route not found', 193 | details: 'The route you are trying to access does not exist', 194 | }); 195 | } 196 | -------------------------------------------------------------------------------- /src/utils/hash.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | 3 | export const sha256 = { 4 | hash: (code: string) => crypto.createHash('sha256').update(code).digest('hex'), 5 | verify: (code: string, hashedCode: string) => 6 | crypto.createHash('sha256').update(code).digest('hex') === hashedCode, 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import JWT from 'jsonwebtoken'; 3 | import { BackendError } from './errors'; 4 | 5 | const JWT_CONFIG: JWT.SignOptions = { 6 | expiresIn: '10m', 7 | }; 8 | 9 | const { JWT_SECRET } = process.env; 10 | 11 | export default function generateToken(userId: string): string { 12 | return JWT.sign({ userId }, JWT_SECRET, JWT_CONFIG); 13 | } 14 | 15 | export function verifyToken(token: string) { 16 | try { 17 | const data = JWT.verify(token, JWT_SECRET); 18 | 19 | return data as { userId: string }; 20 | } 21 | catch (err) { 22 | if (err instanceof JWT.TokenExpiredError) { 23 | throw new BackendError('UNAUTHORIZED', { 24 | message: 'Token expired', 25 | }); 26 | } 27 | 28 | throw new BackendError('UNAUTHORIZED', { 29 | message: 'Invalid token', 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import consola from 'consola'; 2 | import type { NextFunction, Request, Response } from 'express'; 3 | 4 | export function logger(req: Request, _res: Response, next: NextFunction) { 5 | const ip = req.ip; 6 | const method = req.method; 7 | const url = req.url; 8 | const version = req.httpVersion; 9 | const userAgent = req.headers['user-agent']; 10 | 11 | const message = `${ip} [${method}] ${url} HTTP/${version} ${userAgent}`; 12 | 13 | consola.log(message); 14 | 15 | next(); 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | /* If you're using React: */ 5 | "jsx": "react-jsx", 6 | /* If your code doesn't run in the DOM: */ 7 | "lib": ["es2022"], 8 | "moduleDetection": "force", 9 | "module": "ESNext", 10 | /* If NOT transpiling with TypeScript: */ 11 | "moduleResolution": "Bundler", 12 | /* Aliases */ 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | }, 16 | "resolveJsonModule": true, 17 | "allowJs": true, 18 | /* Strictness */ 19 | "strict": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noEmit": true, 22 | /* Base Options: */ 23 | "esModuleInterop": true, 24 | "verbatimModuleSyntax": true, 25 | "skipLibCheck": true 26 | } 27 | } 28 | --------------------------------------------------------------------------------