node-ts-app-starter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # node-starter-project
2
+
3
+ A high-performance, production-ready Node.js + TypeScript boilerplate generator. This CLI scaffolds a complete backend architecture featuring Supabase, Redis, BullMQ, and Express with best-practice security and validation middleware.
4
+
5
+ ## Quick Start
6
+
7
+ You don't need to install this package globally. Simply run the following command to scaffold a new project:
8
+
9
+ ```
10
+ npx node-ts-starter <your-project-name>
11
+ ```
12
+
13
+ Next Steps:
14
+
15
+ 1. ```cd <your-project-name>```
16
+
17
+ 2. Configure your .env file (see .env.example).
18
+
19
+ 3. Run the command ```npm run dev``` to start the server
20
+
21
+ ## Supabase Type Generation
22
+
23
+ To get full IntelliSense for your database tables, you can generate types directly from your Supabase project.
24
+
25
+ 1. Find your Project ID in your Supabase Dashboard (Settings > General).
26
+
27
+ 2. Open your ```package.json```.
28
+
29
+ 3. Update the ```gen-types``` script by replacing the placeholder ```YOUR_PROJECT_ID``` with your own:
30
+ ```"gen-types": "npx supabase gen types typescript --project-id YOUR_PROJECT_ID --schema public > src/types/database.types.ts"```
31
+
32
+ 4. Run the script ```npm run gen-types``` to generate your supabase typescript definitions directly from your supabase schema.
33
+
34
+ ## Logging with Pino
35
+
36
+ The starter uses Pino for logging. In development, logs are pretty-printed for readability. In production, they are emitted as high-speed JSON for compatibility with log aggregators (like Datadog or ELK).
37
+
38
+ * **Development**: Logs are readable and color-coded.
39
+
40
+ * **Production**: Logs are optimized for minimal CPU overhead.
41
+
42
+ ## Features
43
+
44
+ The generated project comes pre-configured with a modular architecture:
45
+
46
+ * Framework: Express.js (v5+) with TypeScript.
47
+
48
+ * Database & Auth: Integrated Supabase client.
49
+
50
+ * Caching & Queues: Redis integration with BullMQ for universal background job processing.
51
+
52
+ * Security: Helmet, CORS, Express Rate Limit, and XSS Sanitizer.
53
+
54
+ * Validation: Ajv for high-speed JSON Schema validation.
55
+
56
+ * Email: SendGrid service wrapper with support for background sending via BullMQ.
57
+
58
+ * Middleware: Custom Global Error Handler, apiError utility classes and pino middleware to add request ID to every request for traceability.
59
+
60
+ * Logging: Every request is logged using pino with the respective request Id attached for logging and monitoring purposes
61
+
62
+ * Code Quality: ESLint and Prettier pre-configured for TypeScript.
63
+
64
+ ## Folder Structure
65
+
66
+ src/
67
+ ├── config/ # Service configurations (Supabase, Redis, Mail, Queues, Logger)
68
+ ├── middleware/ # Global error handling, validation, logging
69
+ ├── utils/ # reusable helper functions
70
+ ├── routes/ # Express route definitions
71
+ ├── mail/ # Sendgrid mailer service logic
72
+ ├── types/ # TypeScript interfaces and enums
73
+ └── app.ts # Application entry point
74
+
75
+ ## Available Scripts
76
+
77
+ Once the project is generated, you can use the following commands:
78
+
79
+ * ```npm run dev```: Start the development server with ```nodemon``` and ```ts-node```.
80
+
81
+ * ```npm run build```: Compile TypeScript to JavaScript in the ```dist/``` folder.
82
+
83
+ * ```npm run gen-types```: Pull TypeScript types from Supabase.
84
+
85
+ * ```npm start```: Run the compiled production build.
86
+
87
+ * ```npm run lint```: Check for code style and logic issues.
88
+
89
+ * ```npm run format```: Automatically fix code formatting with Prettier.
90
+
91
+ ## Author
92
+
93
+ **George Kwabena Asiedu**
94
+
95
+ * GitHub: george-asiedu
96
+
97
+ * Email: <asiedug41@gmail.com>
package/bin/cli.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const shell = require('shelljs');
5
+
6
+ // Capture the project name from the argument (default to 'my-node-app')
7
+ const projectName = process.argv[2] || 'my-node-app';
8
+ const destination = path.join(process.cwd(), projectName);
9
+ const templatePath = path.join(__dirname, '../template');
10
+
11
+ // Prevent overwriting existing folders
12
+ if (fs.existsSync(destination)) {
13
+ console.error(`Error: Folder "${projectName}" already exists.`);
14
+ process.exit(1);
15
+ }
16
+
17
+ console.log(`Creating a new project in: ${destination}...`);
18
+
19
+ try {
20
+ fs.mkdirSync(destination, { recursive: true });
21
+ fs.cpSync(templatePath, destination, { recursive: true });
22
+ } catch (err) {
23
+ console.error('Error copying template files:', err);
24
+ process.exit(1);
25
+ }
26
+
27
+ const filesToRename = {
28
+ 'gitignore.txt': '.gitignore',
29
+ 'prettierrc.txt': '.prettierrc',
30
+ 'eslint.config.txt': 'eslint.config.mjs'
31
+ };
32
+
33
+ Object.entries(filesToRename).forEach(([oldName, newName]) => {
34
+ const oldPath = path.join(destination, oldName);
35
+ const newPath = path.join(destination, newName);
36
+
37
+ if (fs.existsSync(oldPath)) {
38
+ fs.renameSync(oldPath, newPath);
39
+ }
40
+ });
41
+
42
+ // Update the package.json inside the NEW folder with the project name
43
+ const pkgPath = path.join(destination, 'package.json');
44
+ if (fs.existsSync(pkgPath)) {
45
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
46
+ pkg.name = projectName; // Change "starter-project" to whatever the user typed
47
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
48
+ }
49
+
50
+ console.log('Installing dependencies, this may take a moment...');
51
+
52
+ shell.cd(destination);
53
+ if (shell.exec('npm install').code !== 0) {
54
+ console.error('Error installing dependencies');
55
+ process.exit(1);
56
+ }
57
+ console.log(`
58
+ Success! Created ${projectName} at ${destination}
59
+ To get started:
60
+ cd ${projectName}
61
+ npm run dev
62
+ `);
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "node-ts-app-starter",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool to scaffold a Node.js + TypeScript project with Supabase, Redis, and Express",
5
+ "main": "bin/cli.js",
6
+ "bin": {
7
+ "create-node-app": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "template"
12
+ ],
13
+ "scripts": {
14
+ "test-local": "npm link",
15
+ "publish-project": "npm publish --access public"
16
+ },
17
+ "author": "george-asiedu <asiedug41@gmail.com>",
18
+ "license": "ISC",
19
+ "dependencies": {
20
+ "shelljs": "^0.10.0"
21
+ }
22
+ }
@@ -0,0 +1,12 @@
1
+ PORT=your-port
2
+ ENV=development
3
+ SUPABASE_URL=your-supabase-url
4
+ SUPABASE_KEY=your-supabase-key
5
+ SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
6
+ REDIS_HOST=your-redis-host
7
+ REDIS_PORT=your-redis-port
8
+ REDIS_USERNAME=default
9
+ REDIS_PASSWORD=your-redis-password
10
+ SENDGRID_API_KEY=your-sendgrid-api-key
11
+ SENDER_EMAIL=your-sender-email
12
+ CLIENT_URL=your-client-url
@@ -0,0 +1,14 @@
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import tseslint from "typescript-eslint";
4
+ import { defineConfig } from "eslint/config";
5
+ import prettierPlugin from "eslint-plugin-prettier";
6
+
7
+ export default defineConfig([
8
+ { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js, "@typescript-eslint": tseslint, prettier: prettierPlugin }, extends: ["js/recommended", "eslint:recommended",
9
+ "plugin:@typescript-eslint/recommended",
10
+ "plugin:prettier/recommended",], languageOptions: { globals: globals.node }, rules: {
11
+ "prettier/prettier": "error",
12
+ }, },
13
+ tseslint.configs.recommended,
14
+ ]);
@@ -0,0 +1,141 @@
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ lerna-debug.log*
8
+
9
+ # Diagnostic reports (https://nodejs.org/api/report.html)
10
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11
+
12
+ # Runtime data
13
+ pids
14
+ *.pid
15
+ *.seed
16
+ *.pid.lock
17
+
18
+ # Directory for instrumented libs generated by jscoverage/JSCover
19
+ lib-cov
20
+
21
+ # Coverage directory used by tools like istanbul
22
+ coverage
23
+ *.lcov
24
+
25
+ # nyc test coverage
26
+ .nyc_output
27
+
28
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29
+ .grunt
30
+
31
+ # Bower dependency directory (https://bower.io/)
32
+ bower_components
33
+
34
+ # node-waf configuration
35
+ .lock-wscript
36
+
37
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
38
+ build/Release
39
+
40
+ # Dependency directories
41
+ node_modules/
42
+ jspm_packages/
43
+
44
+ # Snowpack dependency directory (https://snowpack.dev/)
45
+ web_modules/
46
+
47
+ # TypeScript cache
48
+ *.tsbuildinfo
49
+
50
+ # Optional npm cache directory
51
+ .npm
52
+
53
+ # Optional eslint cache
54
+ .eslintcache
55
+
56
+ # Optional stylelint cache
57
+ .stylelintcache
58
+
59
+ # Optional REPL history
60
+ .node_repl_history
61
+
62
+ # Output of 'npm pack'
63
+ *.tgz
64
+
65
+ # Yarn Integrity file
66
+ .yarn-integrity
67
+
68
+ # dotenv environment variable files
69
+ .env
70
+ .env.*
71
+ !.env.example
72
+
73
+ # parcel-bundler cache (https://parceljs.org/)
74
+ .cache
75
+ .parcel-cache
76
+
77
+ # Next.js build output
78
+ .next
79
+ out
80
+
81
+ # Nuxt.js build / generate output
82
+ .nuxt
83
+ dist
84
+
85
+ # Gatsby files
86
+ .cache/
87
+ # Comment in the public line in if your project uses Gatsby and not Next.js
88
+ # https://nextjs.org/blog/next-9-1#public-directory-support
89
+ # public
90
+
91
+ # vuepress build output
92
+ .vuepress/dist
93
+
94
+ # vuepress v2.x temp and cache directory
95
+ .temp
96
+ .cache
97
+
98
+ # Sveltekit cache directory
99
+ .svelte-kit/
100
+
101
+ # vitepress build output
102
+ **/.vitepress/dist
103
+
104
+ # vitepress cache directory
105
+ **/.vitepress/cache
106
+
107
+ # Docusaurus cache and generated files
108
+ .docusaurus
109
+
110
+ # Serverless directories
111
+ .serverless/
112
+
113
+ # FuseBox cache
114
+ .fusebox/
115
+
116
+ # DynamoDB Local files
117
+ .dynamodb/
118
+
119
+ # Firebase cache directory
120
+ .firebase/
121
+
122
+ # TernJS port file
123
+ .tern-port
124
+
125
+ # Stores VSCode versions used for testing VSCode extensions
126
+ .vscode-test
127
+
128
+ # yarn v3
129
+ .pnp.*
130
+ .yarn/*
131
+ !.yarn/patches
132
+ !.yarn/plugins
133
+ !.yarn/releases
134
+ !.yarn/sdks
135
+ !.yarn/versions
136
+
137
+ # Vite logs files
138
+ vite.config.js.timestamp-*
139
+ vite.config.ts.timestamp-*
140
+ .vscode/
141
+ .idea/
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "starter-project",
3
+ "version": "1.0.0",
4
+ "description": "A high-performance, production-ready Node.js + TypeScript boilerplate generator. This CLI scaffolds a complete backend architecture featuring Supabase, Redis, BullMQ, and Express with best-practice security and validation middleware.",
5
+ "main": "app.ts",
6
+ "scripts": {
7
+ "build:deploy": "npm install && npm build",
8
+ "build": "tsc",
9
+ "gen-types": "npx supabase gen types typescript --project-id YOUR_PROJECT_ID --schema public > src/types/database.types.ts",
10
+ "dev": "tsx watch src/app.ts",
11
+ "start": "node dist/app.js",
12
+ "lint": "eslint . --ext .ts",
13
+ "lint:fix": "eslint . --ext .ts --fix",
14
+ "format": "prettier --write ."
15
+ },
16
+ "keywords": [
17
+ "node",
18
+ "typescript",
19
+ "starter",
20
+ "utils",
21
+ "template"
22
+ ],
23
+ "author": "george-asiedu <asiedug41@gmail.com>",
24
+ "type": "module",
25
+ "license": "ISC",
26
+ "dependencies": {
27
+ "@sendgrid/mail": "^8.1.6",
28
+ "@supabase/supabase-js": "^2.97.0",
29
+ "@types/multer": "^2.0.0",
30
+ "ajv": "^8.18.0",
31
+ "ajv-errors": "^3.0.0",
32
+ "ajv-formats": "^3.0.1",
33
+ "bullmq": "^5.69.3",
34
+ "compression": "^1.8.1",
35
+ "cookie-parser": "^1.4.7",
36
+ "cors": "^2.8.6",
37
+ "dotenv": "^17.3.1",
38
+ "express": "^5.2.1",
39
+ "express-rate-limit": "^8.2.1",
40
+ "express-xss-sanitizer": "^2.0.1",
41
+ "helmet": "^8.1.0",
42
+ "morgan": "^1.10.1",
43
+ "multer": "^2.0.2",
44
+ "pino": "^10.3.1",
45
+ "pino-http": "^11.0.0",
46
+ "redis": "^5.11.0",
47
+ "uuid": "^13.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^10.0.1",
51
+ "@types/compression": "^1.8.1",
52
+ "@types/cookie-parser": "^1.4.10",
53
+ "@types/cors": "^2.8.19",
54
+ "@types/express": "^5.0.6",
55
+ "@types/morgan": "^1.9.10",
56
+ "@types/node": "^25.3.0",
57
+ "@types/sendgrid": "^2.0.31",
58
+ "@types/uuid": "^10.0.0",
59
+ "eslint": "^10.0.0",
60
+ "eslint-config-prettier": "^10.1.8",
61
+ "eslint-plugin-prettier": "^5.5.5",
62
+ "globals": "^17.3.0",
63
+ "jiti": "^2.6.1",
64
+ "nodemon": "^3.1.11",
65
+ "prettier": "^3.8.1",
66
+ "ts-node": "^10.9.2",
67
+ "tsx": "^4.21.0",
68
+ "typescript": "^5.9.3",
69
+ "typescript-eslint": "^8.56.0"
70
+ }
71
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "singleQuote": true,
3
+ "semi": true,
4
+ "trailingComma": "all"
5
+ }
@@ -0,0 +1,85 @@
1
+ import express, { NextFunction, Request, Response } from 'express';
2
+ import cors from 'cors';
3
+ import helmet from 'helmet';
4
+ import morgan from 'morgan';
5
+ import compression from 'compression';
6
+ import rateLimit from 'express-rate-limit';
7
+ import cookieParser from 'cookie-parser';
8
+ import { pinoMiddleware } from './middleware/pino.js';
9
+ import { env } from './config/env.config.js';
10
+ import { redisClient } from './config/redis.config.js';
11
+ import { supabase } from './config/supabase.config.js';
12
+ import { globalErrorHandler } from './middleware/globalErrorHandler.js';
13
+ import routes from './routes/index.js';
14
+ import logger from './config/logger.config.js';
15
+
16
+ const app = express();
17
+ app.use(pinoMiddleware);
18
+
19
+ app.use(morgan('dev'));
20
+ app.use(
21
+ helmet({
22
+ contentSecurityPolicy: false,
23
+ }),
24
+ );
25
+ app.use(cors());
26
+
27
+ const limiter = rateLimit({
28
+ windowMs: 15 * 60 * 1000,
29
+ limit: 100,
30
+ message: "Too many requests from this IP, please try again later.",
31
+ });
32
+ app.use("/api", limiter);
33
+
34
+ app.use(compression());
35
+ app.use(cookieParser());
36
+ app.use(express.json({ limit: "10mb" }));
37
+ app.use(express.urlencoded({ extended: true }));
38
+
39
+ app.get('/', (_req: Request, res: Response) => {
40
+ res.send('Server is running!');
41
+ });
42
+
43
+ app.get("/health", async (_req: Request, res: Response) => {
44
+ const healthStatus: any = {
45
+ status: "ok",
46
+ uptime: process.uptime(),
47
+ timestamp: new Date().toISOString(),
48
+ services: {
49
+ server: "healthy",
50
+ redis: "unknown",
51
+ supabase: "unknown",
52
+ }
53
+ };
54
+
55
+ try {
56
+ const redisPing = await redisClient.ping();
57
+ healthStatus.services.redis = redisPing === 'PONG' ? 'healthy' : 'unhealthy';
58
+
59
+ const { error } = await supabase.from("profiles").select("id").limit(1);
60
+ healthStatus.services.supabase = error ? 'unhealthy' : 'healthy';
61
+
62
+ } catch (error) {
63
+ healthStatus.status = "error";
64
+ logger.error({ err: error as Error }, 'Health check failed');
65
+ }
66
+
67
+ const isHealthy = healthStatus.services.redis === 'healthy' &&
68
+ healthStatus.services.supabase === 'healthy';
69
+
70
+ res.status(isHealthy ? 200 : 503).json(healthStatus);
71
+ });
72
+
73
+ app.use("/api", routes);
74
+
75
+ app.use((err: Error, req: Request, res: Response, next: NextFunction) =>
76
+ globalErrorHandler(err, req, res, next),
77
+ );
78
+
79
+ const port = env.port;
80
+ if (!port)
81
+ throw new Error("Port number is not defined in environment variables");
82
+
83
+ app.listen(port, () => {
84
+ logger.log(`Server is running on port ${port}`);
85
+ });
@@ -0,0 +1,42 @@
1
+ import dotenv from 'dotenv';
2
+ import path from 'path';
3
+
4
+ const envFilePath = path.resolve(process.cwd(), `.env`);
5
+
6
+ dotenv.config({ path: envFilePath });
7
+ const requiredVars = [
8
+ 'PORT',
9
+ 'ENV',
10
+ 'SUPABASE_URL',
11
+ 'SUPABASE_KEY',
12
+ 'SUPABASE_SERVICE_ROLE_KEY',
13
+ 'SENDGRID_API_KEY',
14
+ 'SENDER_EMAIL',
15
+ 'REDIS_HOST',
16
+ 'REDIS_PORT',
17
+ 'REDIS_USERNAME',
18
+ 'REDIS_PASSWORD',
19
+ 'CLIENT_URL',
20
+ ];
21
+ const missing = requiredVars.filter(v => !process.env[v]);
22
+
23
+ if (missing.length > 0) {
24
+ throw new Error(
25
+ `Missing required environment variables in ${envFilePath}: ${missing.join(', ')}`,
26
+ );
27
+ }
28
+
29
+ export const env = {
30
+ port: Number(process.env.PORT),
31
+ supabaseUrl: process.env.SUPABASE_URL as string,
32
+ supabaseKey: process.env.SUPABASE_KEY as string,
33
+ supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY as string,
34
+ nodeEnv: process.env.NODE_ENV as string,
35
+ redisHost: process.env.REDIS_HOST as string,
36
+ redisPort: Number(process.env.REDIS_PORT),
37
+ redisUsername: process.env.REDIS_USERNAME as string,
38
+ redisPassword: process.env.REDIS_PASSWORD as string,
39
+ sendGridApiKey: process.env.SENDGRID_API_KEY as string,
40
+ senderEmail: process.env.SENDER_EMAIL as string,
41
+ clientUrl: process.env.CLIENT_URL as string,
42
+ };
@@ -0,0 +1,14 @@
1
+ import { env } from './env.config.js';
2
+ import pino from 'pino';
3
+
4
+ const loggerOptions = {
5
+ level: env.nodeEnv === 'production' ? 'info' : 'debug',
6
+ redact: ['req.headers.authorization', 'body.password'],
7
+ ...(env.nodeEnv !== 'production' && {
8
+ transport: { target: 'pino-pretty', options: { colorize: true } },
9
+ }),
10
+ };
11
+
12
+ const logger = pino(loggerOptions);
13
+
14
+ export default logger;
@@ -0,0 +1,18 @@
1
+ import { Queue } from "bullmq";
2
+ import { bullConnection } from "./redis.config.js";
3
+
4
+ export const mainQueue = new Queue("main-app-queue", {
5
+ connection: bullConnection,
6
+ defaultJobOptions: {
7
+ attempts: 3,
8
+ backoff: { type: "exponential", delay: 1000 },
9
+ removeOnComplete: true,
10
+ },
11
+ });
12
+
13
+ /**
14
+ * Universal helper to add jobs
15
+ */
16
+ export const dispatch = (name: string, data: any) => {
17
+ return mainQueue.add(name, data);
18
+ };
@@ -0,0 +1,21 @@
1
+ import { createClient } from 'redis';
2
+ import { env } from "./env.config.js";
3
+ import logger from './logger.config.js';
4
+
5
+ export const redisClient = createClient({
6
+ username: env.redisUsername,
7
+ password: env.redisPassword,
8
+ socket: {
9
+ host: env.redisHost,
10
+ port: env.redisPort,
11
+ },
12
+ });
13
+
14
+ export const bullConnection = {
15
+ url: env.redisHost + ':' + env.redisPort,
16
+ };
17
+
18
+ redisClient.on('error', (err: any) =>
19
+ logger.error({ err }, 'Redis Client Error'),
20
+ );
21
+ await redisClient.connect();
@@ -0,0 +1,8 @@
1
+ import sgMail from "@sendgrid/mail";
2
+ import { env } from "./env.config.js";
3
+
4
+ const apiKey = env.sendGridApiKey;
5
+
6
+ sgMail.setApiKey(apiKey);
7
+
8
+ export default sgMail;
@@ -0,0 +1,13 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { env } from './env.config.js';
3
+
4
+ const supabaseUrl = env.supabaseUrl;
5
+ const supabaseKey = env.supabaseKey;
6
+ const supabaseServiceRoleKey = env.supabaseServiceRoleKey;
7
+
8
+ export const supabase = createClient(supabaseUrl, supabaseKey);
9
+
10
+ export const supabaseAdmin =
11
+ supabaseServiceRoleKey ?
12
+ createClient(supabaseUrl, supabaseServiceRoleKey)
13
+ : null;
@@ -0,0 +1,42 @@
1
+ import { env } from "../config/env.config.js";
2
+ import logger from "../config/logger.config.js";
3
+ import sgMail from "../config/sendgrid.config.js";
4
+
5
+ interface EmailOptions {
6
+ to: string | string[];
7
+ subject: string;
8
+ text?: string;
9
+ html: string;
10
+ isMultiple?: boolean;
11
+ }
12
+
13
+ export class MailService {
14
+ private static readonly SENDER_EMAIL = env.senderEmail;
15
+
16
+ static async send(options: EmailOptions) {
17
+ const { to, isMultiple = true } = options;
18
+
19
+ const msg = {
20
+ to: options.to,
21
+ from: this.SENDER_EMAIL,
22
+ subject: options.subject,
23
+ text: options.text || options.subject,
24
+ html: options.html,
25
+ };
26
+
27
+ try {
28
+ if (Array.isArray(to) && isMultiple) {
29
+ return await sgMail.sendMultiple(msg);
30
+ }
31
+
32
+ return await sgMail.send(msg);
33
+ } catch (error: any) {
34
+ logger.error(
35
+ { err: error },
36
+ 'SendGrid Error:',
37
+ error.response?.body || error.message,
38
+ );
39
+ throw new Error("Failed to send email");
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,18 @@
1
+ import { HttpCode } from "../types/types.js";
2
+
3
+ export class ApiError extends Error {
4
+ public readonly status: string;
5
+ public readonly statusCode: HttpCode;
6
+ public readonly isOperational: boolean;
7
+
8
+ constructor(message: string, statusCode: HttpCode) {
9
+ super(message);
10
+ Object.setPrototypeOf(this, ApiError.prototype);
11
+
12
+ this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
13
+ this.statusCode = statusCode;
14
+ this.isOperational = true;
15
+
16
+ Error.captureStackTrace(this, this.constructor);
17
+ }
18
+ }
@@ -0,0 +1,60 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { HttpCode } from '../types/types.js';
3
+ import { ApiError } from './apiError.js';
4
+ import logger from '../config/logger.config.js';
5
+
6
+ export const globalErrorHandler = (
7
+ err: Error,
8
+ _req: Request,
9
+ res: Response,
10
+ _next: NextFunction,
11
+ ): void => {
12
+ let error = err;
13
+
14
+ if (typeof err === 'object' && err !== null && 'name' in err) {
15
+ const name = (err as any).name;
16
+ if (name === 'JsonWebTokenError') {
17
+ error = new ApiError('Token is invalid', HttpCode.UNAUTHORIZED_ACCESS);
18
+ } else if (name === 'TokenExpiredError') {
19
+ error = new ApiError('Token expired', HttpCode.UNAUTHORIZED_ACCESS);
20
+ }
21
+ }
22
+
23
+ if (!(error instanceof ApiError)) {
24
+ // CAPTURE THE ORIGINAL MESSAGE AND STACK BEFORE OVERWRITING
25
+ const originalMessage = error.message || 'An unexpected error occurred';
26
+ const originalStack = error.stack;
27
+
28
+ error = new ApiError(
29
+ originalMessage, // Use the real error message in Dev
30
+ HttpCode.INTERNAL_SERVER_ERROR,
31
+ );
32
+
33
+ // Restore the original stack trace so you can debug
34
+ if (originalStack) error.stack = originalStack;
35
+ }
36
+
37
+ const nodeEnv = process.env.NODE_ENV || 'development';
38
+ const appError = error as ApiError;
39
+
40
+ if (nodeEnv === 'development') {
41
+ res.status(appError.statusCode).json({
42
+ status: appError.status,
43
+ message: appError.message,
44
+ stack: appError.stack,
45
+ });
46
+ } else {
47
+ if (appError.isOperational) {
48
+ res.status(appError.statusCode).json({
49
+ status: appError.status,
50
+ message: appError.message,
51
+ });
52
+ } else {
53
+ logger.error({ err: appError }, 'ERROR');
54
+ res.status(500).json({
55
+ status: 'Error',
56
+ message: 'Something went wrong',
57
+ });
58
+ }
59
+ }
60
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This middleware ensures every request has a unique ID.
3
+ * It checks if the API Gateway already provided one (via x-request-id);
4
+ * otherwise, it generates a new one.
5
+ */
6
+
7
+ import { Request, Response } from 'express';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ import pinoHttp from 'pino-http';
10
+ import logger from '../config/logger.config.js';
11
+
12
+
13
+ export const pinoMiddleware = pinoHttp.default({
14
+ logger,
15
+ genReqId: (req: Request) => req.headers['x-request-id'] || uuidv4(),
16
+ customSuccessMessage: (req: Request, _res: Response) =>
17
+ `Request completed: ${req.method} ${req.url}`,
18
+ customErrorMessage: (_req: Request, _res: Response, err: any) =>
19
+ `Request failed: ${err.message}`,
20
+ });
@@ -0,0 +1,5 @@
1
+ import { Router } from "express";
2
+
3
+ const router: Router = Router();
4
+
5
+ export default router;
@@ -0,0 +1,9 @@
1
+ export enum HttpCode {
2
+ BAD_REQUEST = 400,
3
+ UNAUTHORIZED_ACCESS = 401,
4
+ NOT_FOUND = 404,
5
+ INTERNAL_SERVER_ERROR = 500,
6
+ FORBIDDEN = 403,
7
+ CONFLICT = 409,
8
+ NO_CONTENT = 204,
9
+ }
@@ -0,0 +1,30 @@
1
+ import { ErrorObject } from "ajv";
2
+
3
+ export type ValidatorError =
4
+ | ErrorObject<string, Record<string, any>, unknown>
5
+ | { instancePath: string; message: string };
6
+
7
+ // Helper function to safely extract ajv error message
8
+ export const errorMessage = (errors: ValidatorError[] | null | undefined) => {
9
+ return errors
10
+ ?.map((e) => {
11
+ if (e.instancePath === "") {
12
+ return e.message;
13
+ }
14
+ return e.instancePath.replace("/", "") + " : " + e.message;
15
+ })
16
+ .join(", ");
17
+ };
18
+
19
+ //global type-safe request handler
20
+ declare global {
21
+ namespace Express {
22
+ interface Request {
23
+ user: {
24
+ id: string;
25
+ email: string;
26
+ role: string;
27
+ };
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "rootDir": "./src",
4
+ "outDir": "./dist",
5
+ "module": "nodenext",
6
+ "moduleResolution": "NodeNext",
7
+ "target": "ES2022",
8
+ "lib": ["ES2022", "DOM"],
9
+ "types": ["node"],
10
+ "sourceMap": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "exactOptionalPropertyTypes": true,
15
+ "noImplicitReturns": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "strict": true,
19
+ "esModuleInterop": true,
20
+ "isolatedModules": true,
21
+ "noUncheckedSideEffectImports": true,
22
+ "moduleDetection": "force",
23
+ "skipLibCheck": true
24
+ },
25
+ "include": ["src/**/*"],
26
+ "exclude": ["node_modules", "dist"]
27
+ }