first commit

This commit is contained in:
Swapnil
2024-12-22 21:45:08 +05:30
commit 919989de52
72 changed files with 9777 additions and 0 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
NODE_ENV=
PORT=
# JWT
JWT_SECRET=your_jwt_secret
JWT_ACCESS_EXPIRATION_MINUTES=230
JWT_REFRESH_EXPIRATION_DAYS=30
JWT_RESET_PASSWORD_EXPIRATION_MINUTES=10
JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=10
# DataBase
DB_USERNAME=username
DB_PASSWORD=password
DB_DATABASE_NAME=database_name
DB_HOSTNAME=host
DB_PORT=port

2
.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
bin

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Convert text file line endings to lf
* text eol=lf
*.js text

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.idea/
.vscode/
node_modules/
dist/
tmp/
temp/
.env

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
yarn lint-staged

6
.lintstagedrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"*.js": [
"eslint",
"prettier --write **/*.js"
]
}

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": true,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": true,
"endOfLine": "lf"
}

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2024 Swapnil Bendal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# **TypeScript Backend Template**
This backend template follows the principles of CLEAN Architecture to ensure:
- **Separation of Concerns:** Layers are organized to separate business logic, application logic, and infrastructure concerns.
- **Scalability:** Modular and well-organized codebase makes it easy to extend functionality.
- **Testability:** Well-defined boundaries between layers simplify unit and integration testing.
- **Maintainability:** Consistent structure and adherence to SOLID principles reduce technical debt over time.
### **Structure Overview**
- **Core Domain:** Contains the business rules and logic.
- **Use Cases:** Implements application-specific rules and orchestrates the flow of data.
- **Infrastructure:** Deals with external systems (e.g., database, APIs).
- **Presentation:** Manages HTTP communication and routes.
Refer to the documentation for details on how to structure and organize your code.
---
## **Table of Contents**
- [Installation](#installation)
- [Usage](#usage)
- [Environment Variables](#environment-variables)
- [Scripts](#scripts)
- [License](#license)
---
## **Installation**
### **Prerequisites**
- [Node.js](https://nodejs.org/) (version 14 or higher recommended)
- [npm](https://www.npmjs.com/) (bundled with Node.js)
### **Steps**
1. Fork the repository to your GitHub or GitLab account.
2. Clone your forked repository:
```bash
git clone https://<your-git-account>/TypeScript-Backend-Template.git
```
3. Navigate to the project directory:
```bash
cd TypeScript-Backend-Template
```
4. Install dependencies:
```bash
npm install
```
---
## **Usage**
### **Development Mode**
1. Start the development server:
```bash
npm run dev
```
2. Open your browser and navigate to:
```
http://localhost:3000
```
### **Production Mode**
1. Install [PM2](https://pm2.keymetrics.io/) globally for process management:
```bash
npm install pm2 -g
```
2. Start the production server:
```bash
npm start
```
---
## **Environment Variables**
Create a `.env` file in the root directory based on the structure of [`.env.example`](.env.example).
---
## **Scripts**
| Script | Description |
|---------------------|-------------------------------------------------------------|
| `npm start` | Starts the app in production mode using PM2. |
| `npm run dev` | Starts the app in development mode with `nodemon`. |
| `npm run lint` | Runs ESLint to check for code quality issues. |
| `npm run lint:fix` | Fixes fixable issues detected by ESLint. |
| `npm run prettier` | Checks code formatting using Prettier. |
| `npm run prettier:fix` | Formats code files according to Prettier rules. |
| `npm run prepare` | Prepares Husky for managing Git hooks. |
---
## **License**
This project is licensed under the [MIT License](LICENSE).

17
ecosystem.config.json Normal file
View File

@@ -0,0 +1,17 @@
{
"apps": [
{
"name": "Typescript-Backend",
"script": "npm",
"args": "run start",
"instances": 1,
"autorestart": true,
"watch": false,
"time": true,
"env": {
"NODE_ENV": "production",
"PORT": 3000
}
}
]
}

14
eslint.config.mjs Normal file
View File

@@ -0,0 +1,14 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
}
},
);

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "tanami-backend",
"version": "v1.0.0",
"main": "src/index.ts",
"author": "Swapnil Bendal",
"license": "MIT",
"scripts": {
"dev": "cross-env NODE_ENV=development nodemon src/index.ts",
"start": "ts-node src/index.ts",
"typeorm": "cross-env NODE_ENV=development typeorm-ts-node-commonjs",
"build": "tsc",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prettier": "prettier --check **/*.js",
"prettier:fix": "prettier --write **/*.js",
"prepare": "husky"
},
"dependencies": {
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"inversify": "^6.2.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.1",
"reflect-metadata": "^0.1.13",
"request-ip": "^3.3.0",
"typeorm": "0.3.20",
"winston": "^3.17.0",
"yup": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^9.16.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/node": "^16.11.10",
"@types/request-ip": "^0.0.41",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"cross-env": "^7.0.3",
"eslint": "^9.16.0",
"eslint-plugin-import": "^2.31.0",
"globals": "^15.13.0",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"nodemon": "^3.1.7",
"ts-node": "10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0"
}
}

53
src/app.ts Normal file
View File

@@ -0,0 +1,53 @@
import requestId from 'request-ip';
import express, { Application, NextFunction, Request, Response } from "express";
import config from "./config/config";
import morgan from "./config/morgan";
import path from 'path';
import logger from './config/logger';
import error from './middleware/error';
import routes from './routes';
import ApiError from './utils/helper/ApiError';
class App {
private app: Application;
constructor() {
this.app = express();
this.initializeMiddlewares();
this.initializeRoutes();
this.initializeErrorHandling();
}
private initializeMiddlewares(): void {
if (config.env !== "test") {
this.app.use(morgan.successHandler);
this.app.use(morgan.errorHandler);
}
this.app.use(requestId.mw());
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
this.app.use("/public", express.static(path.join(__dirname, "../public")));
}
private initializeRoutes(): void {
this.app.use('/api', routes);
// Define our routes
this.app.use((req: Request, res: Response, next: NextFunction) => {
next(new ApiError(404, "Not found"));
})
}
private initializeErrorHandling(): void {
this.app.use(error.errorConverter);
this.app.use(error.errorHandler);
}
public listen(port: number): ReturnType<typeof this.app.listen> {
return this.app.listen(port, () => {
logger.info(`Server listening on port ${config.port}`);
logger.info(`Environment :- ${config.env}`);
})
}
}
export default new App();

89
src/config/config.ts Normal file
View File

@@ -0,0 +1,89 @@
import dotenv from "dotenv";
import path from "path";
import * as yup from 'yup';
dotenv.config({ path: path.join(__dirname, '../../.env') });
const envVarsSchema = yup.object().shape({
NODE_ENV: yup.string().oneOf(["production", "development", "test"]).required(),
PORT: yup.number().default(3000),
JWT_SECRET: yup.string().required('JWT secret key is required'),
JWT_ACCESS_EXPIRATION_MINUTES: yup.number().default(30).required('minutes after which access tokens expire'),
JWT_REFRESH_EXPIRATION_DAYS: yup.number().default(30).required('days after which refresh tokens expire'),
JWT_RESET_PASSWORD_EXPIRATION_MINUTES: yup.number().default(10).required('minutes after which reset password token expires'),
JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: yup.number().default(10).required('minutes after which verify email token expires'),
// DataBase
DB_USERNAME: yup.string().required('DB Username is required'),
DB_PASSWORD: yup.string().required('DB Password is required'),
DB_DATABASE_NAME: yup.string().required('Database name is required'),
DB_HOSTNAME: yup.string().default('127.0.0.1').required('DB Hostname is required'),
DB_PORT: yup.number().default(3306).required('DB Port is required'),
}).noUnknown(true)
// Validate and prepare the configuration
function getConfig() {
try {
// Validate the environment variables
const envVars = envVarsSchema.validateSync(process.env, {
abortEarly: false, // Validate all fields before throwing errors
stripUnknown: true, // Remove fields not in the schema
});
// Return the validated configuration
return {
env: envVars.NODE_ENV,
port: envVars.PORT,
jwt: {
secret: envVars.JWT_SECRET,
accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES,
refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS,
resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES,
verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES,
},
database: {
development: {
host: envVars.DB_HOSTNAME,
port: envVars.DB_PORT,
username: envVars.DB_USERNAME,
password: envVars.DB_PASSWORD,
database: envVars.DB_DATABASE_NAME,
logging: false,
},
test: {
host: envVars.DB_HOSTNAME,
port: envVars.DB_PORT,
username: envVars.DB_USERNAME,
password: envVars.DB_PASSWORD,
database: envVars.DB_DATABASE_NAME,
logging: false,
socketPath: "/var/run/mysqld/mysqld.sock",
},
production: {
host: envVars.DB_HOSTNAME,
port: envVars.DB_PORT,
username: envVars.DB_USERNAME,
password: envVars.DB_PASSWORD,
database: envVars.DB_DATABASE_NAME,
logging: false,
socketPath: "/var/run/mysqld/mysqld.sock",
},
},
};
} catch (error: unknown) {
if (error instanceof yup.ValidationError) {
console.error("Validation Errors:", error.errors.join(", "));
} else {
console.error("Unexpected error during configuration validation:", error);
}
console.error("Server shut down due to incomplete environment variable configuration.");
process.exit(1); // Exit with error code 1
}
}
// Validate and export configuration only if validation succeeds
const config = getConfig();
export default config;

17
src/config/data-source.ts Normal file
View File

@@ -0,0 +1,17 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import config from "./config"
import path from "path"
export const AppDataSource = new DataSource({
type: "postgres",
host: config.database[config.env].host, // Dynamically set host based on environment
port: config.database[config.env].port, // Dynamically set port based on environment
username: config.database[config.env].username, // Dynamically set username
password: config.database[config.env].password, // Dynamically set password
database: config.database[config.env].database, // Dynamically set database
synchronize: true,
entities: [path.resolve(__dirname, "../entities/**/*.ts")],
migrations: [path.resolve(__dirname, "../migration/**/*.ts")],
subscribers: [],
})

44
src/config/logger.ts Normal file
View File

@@ -0,0 +1,44 @@
import winston, { Logger, format } from "winston";
import config from "./config";
const logger: Logger = winston.createLogger({
level: config.env === "development" ? "debug" : "info",
format: format.combine(
format.splat(), // Supports string interpolation
format.timestamp(), // Adds timestamp to logs
format.printf((info) => {
const { timestamp, level, message } = info as {
timestamp: string;
level: string;
message?: unknown;
};
return `${timestamp ? new Date(timestamp).toLocaleString() : ""} ${level}: ${message}`;
})
),
transports: [
// Log to console
new winston.transports.Console({
level: config.env === "development" ? "debug" : "info",
stderrLevels: ["error"], // Log errors to stderr
format: format.combine(
format.colorize(), // Adds color for console output
format.printf((info) => `${info.level}: ${info.message}`)
),
}),
// Optional: Log to a file in production
...(config.env === "production"
? [
new winston.transports.File({
filename: "logs/error.log",
level: "error",
}),
new winston.transports.File({
filename: "logs/combined.log",
}),
]
: []),
],
});
export default logger;

28
src/config/morgan.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Request, Response } from "express";
import requestId from "request-ip";
import morgan from "morgan";
import config from "./config";
import logger from "./logger";
morgan.token('clientId', (req: Request) => requestId.getClientIp(req) || "");
morgan.token('message', (req: Request, res: Response) => res.locals.errorMessage || "");
// Formats based on environment
const ipFormat = config.env === 'production' ? ':clientIp - ' : '';
const successFormat = `${ipFormat}:method :url :status - :response-time ms`;
const errorFormat = `${ipFormat}:method :url :status - :response-time ms - message: :message`;
// Handlers
const successHandler = morgan(successFormat, {
skip: (req: Request, res: Response) => res.statusCode >= 400,
stream: { write: (msg) => logger.info(msg.trim()) },
});
const errorHandler = morgan(errorFormat, {
skip: (req: Request, res: Response) => res.statusCode < 400,
stream: { write: (msg) => logger.error(msg.trim()) },
});
export default {
successHandler, errorHandler,
}

View File

@@ -0,0 +1,33 @@
import { Request, Response } from 'express';
import { AsyncHandler } from "../../utils";
import { IIamPrincipalInteractor, ITokenInteractor } from "../../interfaces/interactor";
import ApiError from '../../utils/helper/ApiError';
import { compareSync } from 'bcrypt';
import ApiResponse from '../../utils/helper/ApiResponse';
export class LoginController {
private tokenInteractor: ITokenInteractor;
private iamPrincipalInteractor: IIamPrincipalInteractor;
constructor(tokenInteractor: ITokenInteractor, iamPrincipalInteractor: IIamPrincipalInteractor) {
this.tokenInteractor = tokenInteractor;
this.iamPrincipalInteractor = iamPrincipalInteractor;
}
@AsyncHandler()
async login(req: Request, res: Response) {
const { email, password } = req.body;
const principal = await this.iamPrincipalInteractor.getByEmailPrincipal(email);
if (!principal) { throw new ApiError(400, 'Invalid email or password'); }
const isValidPassword = compareSync(password, principal.password_hash);
if (!isValidPassword) { throw new ApiError(400, 'Invalid email or password'); }
await this.iamPrincipalInteractor.updateLastLogin(principal.id);
const tokens = await this.tokenInteractor.generateAuthToken(principal.id);
res.status(200).json(new ApiResponse(200, tokens, 'Login success',));
}
}

View File

@@ -0,0 +1,20 @@
import { genSaltSync, hashSync } from 'bcrypt';
import { Request, Response } from 'express';
import { AsyncHandler } from "../../utils";
import { IIamPrincipalInteractor } from "../../interfaces/interactor";
import ApiResponse from '../../utils/helper/ApiResponse';
export class RegistrationController {
private readonly interactor: IIamPrincipalInteractor;
constructor(interactor: IIamPrincipalInteractor) {
this.interactor = interactor;
}
@AsyncHandler()
async registerEmail(req: Request, res: Response) {
const salt = genSaltSync(10);
await this.interactor.createPrincipal({ password_hash: hashSync(req.body.password, salt), ...req.body });
res.status(200).json(new ApiResponse(200, {}, 'Registration success',));
}
}

3
src/controllers/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './product/productController';
export * from './auth/login.controller';
export * from './auth/registration.controller';

View File

@@ -0,0 +1,53 @@
import { Request, Response } from 'express';
import { IProductInteractor } from '../../interfaces/interactor/product/IProductInteractor';
import ApiResponse from '../../utils/helper/ApiResponse';
import { AsyncHandler } from '../../utils/handler/async.handler';
import { inject, injectable } from 'inversify';
import { INTERFACE_TYPE } from '../../utils';
@injectable()
export class ProductController {
private interactor: IProductInteractor;
constructor(
@inject(INTERFACE_TYPE.ProductInteractor) interactor: IProductInteractor
) {
this.interactor = interactor;
}
@AsyncHandler()
async onCreateProduct(req: Request, res: Response) {
const data = await this.interactor.createProduct(req.body);
res.status(201).json(new ApiResponse(201, data, 'Successfully created'));
}
@AsyncHandler()
async onGetProducts(req: Request, res: Response) {
const offset = Number.isInteger(Number(req.query.offset)) ? parseInt(`${req.query.offset}`, 10) : 0;
const limit = Number.isInteger(Number(req.query.limit)) ? parseInt(`(${req.query.limit}`, 10) : 10;
const data = await this.interactor.getProducts(limit, offset);
if (data.length === 0) {
res.status(200).json(new ApiResponse(200, null, 'No products found'));
return;
}
res.status(200).json(new ApiResponse(200, data, 'Products retrieved successfully'));
}
@AsyncHandler()
async onGetByIDProducts(req: Request, res: Response) {
const data = await this.interactor.getByIdProduct(Number(req.params.id));
if (!data) {
res.status(200).json(new ApiResponse(200, null, 'No products found'));
return;
}
res.status(200).json(new ApiResponse(200, data, 'Products retrieved successfully'));
}
@AsyncHandler()
async onUpdateStock(req: Request, res: Response) {
const data = await this.interactor.updateStock(parseInt(`${req.params.id}`, 10), parseInt(`${req.body.stock}`, 10));
res.status(200).json(new ApiResponse(200, data, 'Successfully updated'));
}
}

View File

@@ -0,0 +1,86 @@
import { IamSource } from './../master/iamSource.entity';
import { Column, Entity, JoinColumn, ManyToOne, Unique } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
import { IamType } from "../master/iamType.entity";
@Entity({ name: "iam_principals", })
export class IamPrincipal extends BaseEntity {
@Column({ nullable: true, length: 100 })
username!: string
@Unique(["emailAddress"])
@Column({ length: 100 })
emailAddress!: string
@Column({ nullable: true, select: false })
password_hash!: string
@Column({ nullable: true, length: 100 })
firstName!: string
@Column({ nullable: true, length: 100 })
lastName!: string
@Unique(["phoneNumber"])
@Column({ nullable: true, length: 15 })
phoneNumber!: string
@Column({ nullable: true, length: 255 })
profileImage!: string
@Column({ nullable: true, length: 10 })
gender!: string
@Column({ nullable: true, type: "timestamp" })
dateOfBirth!: Date
@Column({ nullable: true, length: 255 })
address!: string
@Column({ nullable: true, length: 100 })
city!: string
@Column({ nullable: true, length: 100 })
state!: string
@Column({ nullable: true, length: 100 })
country!: string
@Column({ nullable: true, length: 20 })
postalCode!: string
@Column({ default: () => "CURRENT_TIMESTAMP", type: "timestamp" })
lastLogin!: Date;
// Flags
@Column({ nullable: true })
isEmailVerified!: boolean
@Column({ nullable: true })
isPhoneVerified!: boolean
@Column({ nullable: true })
isProfileComplete!: boolean
@Column({ nullable: true })
isBlocked!: boolean
@Column({ nullable: true })
isSuspended!: boolean
@Column({ nullable: true })
isDeleted!: boolean
// Foreign Keys
@ManyToOne(() => IamType, { nullable: true })
@JoinColumn({ name: "principalType_xid" })
principalType!: IamType
@ManyToOne(() => IamSource, { nullable: true })
@JoinColumn({ name: "principalSource_xid" })
principalSource!: IamSource
}

View File

@@ -0,0 +1,24 @@
import { Column, CreateDateColumn, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { IamPrincipal } from "../IAM/iamPrincipal.entity";
export abstract class BaseEntity {
@PrimaryGeneratedColumn({ unsigned: true })
id!: number;
@ManyToOne(() => IamPrincipal, creator => creator.id, { nullable: true })
@JoinColumn({ name: 'created_by' })
createdBy!: IamPrincipal;
@ManyToOne(() => IamPrincipal, modifier => modifier.id, { nullable: true })
@JoinColumn({ name: 'modified_by' })
modifiedBy!: IamPrincipal;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}

9
src/entities/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export * from './IAM/iamPrincipal.entity';
export * from './master/appAction.entity';
export * from './master/appAction.entity';
export * from './master/appResource.entity';
export * from './master/iamGroup.entity';
export * from './master/iamRole.entity';
export * from './master/iamSource.entity';
export * from './master/iamType.entity';
export * from './main/token.entity';

View File

@@ -0,0 +1,20 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string
@Column()
description!: string
@Column()
price!: number
@Column()
stock!: number
}

18
src/entities/main/User.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"
@Entity()
export class User {
@PrimaryGeneratedColumn()
id!: number
@Column()
firstName!: string
@Column()
lastName!: string
@Column()
age!: number
}

View File

@@ -0,0 +1,22 @@
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
import { IamPrincipal } from "../IAM/iamPrincipal.entity";
@Entity({ name: "tokens" })
export class Token extends BaseEntity {
@Column()
token!: string;
@Column({ type: "timestamp" })
expiresAt!: Date;
@Column()
type!: string;
@Column({ default: false })
isBlacklisted!: boolean;
@ManyToOne(() => IamPrincipal, principal => principal.id, { nullable: true })
@JoinColumn({ name: 'principal_xid' })
iamPrincipal!: IamPrincipal;
}

View File

@@ -0,0 +1,8 @@
import { Column, Entity } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
@Entity({ name: "app_actions" })
export class appAction extends BaseEntity {
@Column({ length: 50 })
action!: string
}

View File

@@ -0,0 +1,8 @@
import { Column, Entity } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
@Entity({ name: "app_resources" })
export class appResource extends BaseEntity{
@Column({ length: 50 })
resource!: string
}

View File

@@ -0,0 +1,8 @@
import { Column, Entity } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
@Entity({ name: "iam_groups" })
export class IamGroup extends BaseEntity {
@Column({ length: 50 })
group!: string
}

View File

@@ -0,0 +1,8 @@
import { Column, Entity } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
@Entity({ name: "iam_roles" })
export class IamRole extends BaseEntity {
@Column({ length: 50 })
role!: string
}

View File

@@ -0,0 +1,9 @@
import { Column, Entity } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
@Entity({ name: "iam_sources" })
export class IamSource extends BaseEntity {
@Column({ length: 50 })
source!: string
}

View File

@@ -0,0 +1,9 @@
import { Column, Entity } from "typeorm";
import { BaseEntity } from "../abstract/base.entity";
@Entity({ name: "iam_types" })
export class IamType extends BaseEntity {
@Column({ length: 50 })
type!: string
}

View File

@@ -0,0 +1,23 @@
import { injectable } from "inversify";
import logger from "../config/logger";
import { IMailer } from "../interfaces/external-libraries/IMailer";
@injectable()
export class Mailer implements IMailer {
async sendWelcomeMail(to: string, subject: string, body: unknown): Promise<boolean> {
try {
logger.debug("Sending welcome mail...", { to, subject, body });
// Here you would integrate with your email service provider
// For example, using nodemailer or any other email service
// await emailService.send({ to, subject, body });
return true;
} catch (error) {
logger.error("Failed to send welcome mail", { error });
return false;
}
}
async SendMail(to: string, subject: string, body: unknown): Promise<boolean> {
logger.debug("Sending mail...", { to, subject, body });
return true;
}
}

View File

@@ -0,0 +1,12 @@
import { injectable } from "inversify";
import logger from "../config/logger";
import { IMessageBroker } from "../interfaces/external-libraries/IMessageBroker";
@injectable()
export class MessageBroker implements IMessageBroker {
async NotifyToPromotionService(product: unknown): Promise<boolean> {
logger.debug("Message send ...", product);
return true;
}
}

45
src/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import "reflect-metadata"
import app from "./app";
import config from "./config/config";
import { AppDataSource } from "./config/data-source";
import logger from "./config/logger";
let server: ReturnType<typeof app.listen>;
AppDataSource.initialize()
.then(() => {
server = app.listen(config.port)
logger.info("Data Source has been initialized!")
})
.catch((err: Error) => {
console.log(err);
logger.error("Error during Data Source initialization", err)
process.exit(1)
})
const exitHandler = () => {
if (server) {
server.close(() => {
logger.info('server closed')
process.exit(1)
})
} else {
process.exit(1)
}
}
const unexpectedErrorHandler = (error: Error) => {
logger.error("Un-Expected Error: ", error)
exitHandler()
}
process.on('uncaughtException', unexpectedErrorHandler)
process.on('unhandledRejection', unexpectedErrorHandler)
process.on('SIGALRM', () => {
logger.info('SIGALRM signal received')
if (server) {
server.close()
}
})

View File

@@ -0,0 +1,51 @@
import { IamPrincipal } from "../../entities";
import { IIamPrincipalInteractor } from "../../interfaces/interactor";
import { IIamPrincipalRepository } from '../../interfaces/repository';
export class IamPrincipalInteractor implements IIamPrincipalInteractor {
private repository: IIamPrincipalRepository;
constructor(repository: IIamPrincipalRepository) {
this.repository = repository;
}
async updateLastLogin(id: number): Promise<IamPrincipal> {
const principal = await this.repository.findById(id);
if (!principal) {
throw new Error(`Principal with id ${id} not found`);
}
principal.lastLogin = new Date();
const updatedPrincipal = await this.repository.update(id, principal);
return updatedPrincipal;
}
async checkPrincipalIsExist(email: string): Promise<boolean> {
const principal = await this.repository.findByEmail(email);
return principal !== null;
}
async createPrincipal(data: Partial<IamPrincipal>): Promise<IamPrincipal> {
const newPrincipal = await this.repository.create(data);
return newPrincipal;
}
async updatePrincipal(id: number, data: Partial<IamPrincipal>): Promise<IamPrincipal> {
const updatedPrincipal = await this.repository.update(id, data);
return updatedPrincipal;
}
async getPrincipals(limit: number, offset: number): Promise<IamPrincipal[]> {
const principals = await this.repository.findAll(limit, offset);
return principals;
}
async getByIdPrincipal(id: number): Promise<IamPrincipal> {
const principal = await this.repository.findById(id);
return principal;
}
async getByEmailPrincipal(email: string): Promise<IamPrincipal> {
const principal = await this.repository.findByEmail(email);
return principal;
}
}

3
src/interactors/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './product/productInteractor';
export * from './IAM/iamPrincipalInteractor';
export * from './token/token.interactor';

View File

@@ -0,0 +1,45 @@
import { IProductRepository } from './../../interfaces/repository';
import { IProductInteractor } from './../../interfaces/interactor';
import { IMailer } from "../../interfaces/external-libraries/IMailer";
import { Product } from "../../entities/main/Product";
import { IMessageBroker } from "../../interfaces/external-libraries/IMessageBroker";
import { inject, injectable } from "inversify";
import { INTERFACE_TYPE } from "../../utils";
@injectable()
export class ProductInteractor implements IProductInteractor {
private repository: IProductRepository
private mailer: IMailer
private broker: IMessageBroker
constructor(
@inject(INTERFACE_TYPE.ProductRepository) repository: IProductRepository,
@inject(INTERFACE_TYPE.Mailer) mailer: IMailer,
@inject(INTERFACE_TYPE.MessageBroker) broker: IMessageBroker
) {
this.repository = repository
this.mailer = mailer
this.broker = broker
}
async createProduct(input: never): Promise<Product> {
const data = await this.repository.create(input)
// Do something notify promotion message
await this.broker.NotifyToPromotionService(data)
return data
}
async updateStock(id: number, stock: number): Promise<Product> {
const data = await this.repository.update(id, { stock: stock })
// Do some update Admin update a stock
await this.mailer.SendMail("someone@gmail.com", "Update Stock", data)
return data
}
async getProducts(limit: number, offset: number): Promise<Product[]> {
return await this.repository.find(limit, offset)
}
async getByIdProduct(id: number): Promise<Product> {
return await this.repository.findById(id)
}
}

View File

@@ -0,0 +1,58 @@
import { injectable } from 'inversify';
import { ITokenRepository } from "../../interfaces/repository";
import { ITokenInteractor } from "../../interfaces/interactor";
import { sign } from "jsonwebtoken";
import moment from 'moment';
import config from "../../config/config";
@injectable()
export class TokenInteractor implements ITokenInteractor {
private repository: ITokenRepository
constructor(repository: ITokenRepository) {
this.repository = repository
}
generateToken(principal_xid: number, expiresIn: Date, type: string, secret: string): { token: string; expires: Date; } {
const token = sign(
{ sub: principal_xid, iat: moment().unix(), exp: moment(expiresIn).unix(), type },
// { issuer: 'your-issuer', }, if we need to add issuer
secret,
);
return { token, expires: expiresIn };
}
async generateAuthToken(principal_xid: number): Promise<{ access: { token: string; expires: Date; }; refresh: { token: string; expires: Date; }; }> {
const accessTokenExpires = new Date();
accessTokenExpires.setHours(accessTokenExpires.getMinutes() + config.jwt.accessExpirationMinutes); // Access token expires in 1 hour
const refreshTokenExpires = new Date();
refreshTokenExpires.setDate(refreshTokenExpires.getDate() + 7); // Refresh token expires in 7 days
const accessToken = this.generateToken(principal_xid, accessTokenExpires, 'access', config.jwt.secret);
const refreshToken = this.generateToken(principal_xid, refreshTokenExpires, 'refresh', config.jwt.secret);
await this.repository.saveToken(refreshToken.token, principal_xid, accessToken.expires, 'refresh', false);
return {
access: accessToken,
refresh: refreshToken
};
}
async revokeToken(token: string): Promise<boolean> {
await this.repository.makeBackListToken(token);
return true;
}
async isTokenBlackListed(token: string): Promise<boolean> {
return this.repository.findToken(token).then((data) => {
if (data) {
return true
}
return false
})
}
}

View File

@@ -0,0 +1,4 @@
export interface IMailer {
SendMail(to: string, subject: string, body: unknown): Promise<boolean>;
sendWelcomeMail(to: string, subject: string, body: unknown): Promise<boolean>;
}

View File

@@ -0,0 +1,3 @@
export interface IMessageBroker {
NotifyToPromotionService(product: unknown): Promise<boolean>;
}

View File

@@ -0,0 +1,2 @@
export * from './IMailer';
export * from './IMessageBroker';

View File

@@ -0,0 +1,11 @@
import { IamPrincipal } from "./../../../entities";
export interface IIamPrincipalInteractor {
checkPrincipalIsExist(email: string): Promise<boolean>;
createPrincipal(data: Partial<IamPrincipal>): Promise<IamPrincipal>;
updatePrincipal(id: number, data: Partial<IamPrincipal>): Promise<IamPrincipal>;
getPrincipals(limit: number, offset: number): Promise<IamPrincipal[]>;
getByIdPrincipal(id: number): Promise<IamPrincipal>;
getByEmailPrincipal(email: string): Promise<IamPrincipal>;
updateLastLogin(id: number): Promise<IamPrincipal>;
}

View File

@@ -0,0 +1,3 @@
export * from './IAM/IIamPrincipalInteractor';
export * from './product/IProductInteractor';
export * from './token/ITokenInteractor';

View File

@@ -0,0 +1,8 @@
import { Product } from "../../../entities/main/Product";
export interface IProductInteractor {
createProduct(input: Partial<Product>): Promise<Product>;
updateStock(id: number, stock: number): Promise<Product>;
getProducts(limit: number, offset: number): Promise<Product[]>;
getByIdProduct(id: number): Promise<Product>;
}

View File

@@ -0,0 +1,6 @@
export interface ITokenInteractor {
generateAuthToken(principal_xid: number): Promise<{ access: { token: string, expires: Date }, refresh: { token: string, expires: Date } }>;
revokeToken(token: string): Promise<boolean>;
isTokenBlackListed(token: string): Promise<boolean>;
generateToken(principal_xid: number, expiresIn: Date, type: string, secret: string): { token: string, expires: Date };
}

View File

@@ -0,0 +1,9 @@
import { IamPrincipal } from './../../../entities/IAM/iamPrincipal.entity';
export interface IIamPrincipalRepository {
create(data: Partial<IamPrincipal>): Promise<IamPrincipal>;
update(id: number, data: Partial<IamPrincipal>): Promise<IamPrincipal>;
findAll(limit: number, offset: number): Promise<IamPrincipal[]>;
findById(id: number): Promise<IamPrincipal>;
findByEmail(email: string): Promise<IamPrincipal>;
}

View File

@@ -0,0 +1,3 @@
export * from './IAM/IIamPrincipalRepository';
export * from './product/IProductRepository';
export * from './token/ITokenRepository';

View File

@@ -0,0 +1,8 @@
import { Product } from "../entities/Product";
export interface IProductRepository {
create(data: Product): Promise<Product>;
update(id: number, data: Partial<Product>): Promise<Product>;
find(limit: number, offset: number): Promise<Product[]>;
findById(id: number): Promise<Product>;
}

View File

@@ -0,0 +1,7 @@
import { Token } from "../../../entities";
export interface ITokenRepository {
saveToken(token: string, principal_xid: number, expiresAt: Date, type: string, isBlacklisted: boolean): Promise<Token>;
findToken(token: string): Promise<Token | null>;
makeBackListToken(token: string): Promise<Token | null>;
}

76
src/middleware/error.ts Normal file
View File

@@ -0,0 +1,76 @@
import { Request, Response, NextFunction } from 'express';
import ApiError from '../utils/helper/ApiError';
import config from '../config/config';
import logger from '../config/logger';
import ApiResponse from '../utils/helper/ApiResponse';
class error {
static errorConverter(
err: ApiError,
req: Request,
res: Response,
next: NextFunction
): void {
// Define a broader type for error
let error: ApiError | Error & { statusCode?: number; errors?: Error[] } = err;
// Handle Sequelize validation and unique constraint errors
if (error.errors && Array.isArray(error.errors)) {
const messages = error.errors.map((e: Error) => e.message);
error = new ApiError(
400,
messages.join(', '),
error.errors,
true,
err.stack
);
}
if (!(error instanceof ApiError)) {
// Handle other errors
const statusCode = error.statusCode || 500;
const message = error.message || "Something went wrong";
error = new ApiError(statusCode, message, [], false, err.stack);
}
next(error);
}
static errorHandler(
err: ApiError,
req: Request,
res: Response,
_next: NextFunction // Retain this even if unused
): void {
// Extract error details with fallback defaults
let { statusCode = 500, message = "Internal server error" } = err;
// Production: Ensure only operational errors reveal their messages
if (config.env === "production" && !err.isOperational) {
statusCode = 500;
message = "An unexpected error occurred";
}
// Attach error message to response locals for potential templating
res.locals.errorMessage = err.message;
// Log the error in all environments
logger.error({
message: err.message,
stack: err.stack,
statusCode,
errors: err.errors || [],
});
// Send the response
res.status(statusCode).json(new ApiResponse(statusCode, null, message, {
stack: config.env === "development" && err.stack ? err.stack : undefined,
errors: config.env === "development" && err.errors ? err.errors.map((e: Error) => e.message) : [],
}));
// Do not call `next()` here, as this is the final error handler.
}
}
export default error;

View File

@@ -0,0 +1,77 @@
import multer, { StorageEngine } from 'multer';
import ApiError from '../utils/helper/ApiError';
import { existsSync, mkdirSync } from 'fs';
import path from 'path';
const maxFileSize = 10 * 1024 * 1024; // 10 MB (in bytes)
const allowedFileTypes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'video/mp4',
'video/x-msvideo',
'video/x-matroska',
'video/webm',
'video/ogg',
'video/quicktime',
];
const createFolder = (folderPath: string): void => {
if (!existsSync(folderPath)) {
try {
mkdirSync(folderPath, { recursive: true });
} catch (err) {
throw new Error(`Failed to create folder at ${folderPath}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
};
const storage = (folderName: string = 'asset'): StorageEngine => multer.diskStorage({
destination: (req, file, cb: (error: Error | null, destination: string) => void): void => {
try {
const basePath = path.resolve('public');
createFolder(basePath);
const targetPath = path.join(basePath, folderName);
createFolder(targetPath);
cb(null, targetPath);
} catch (err) {
cb(err as Error, '');
}
},
filename: (req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
cb(null, `${baseName}-${uniqueSuffix}${ext}`);
}
});
const fileFilter = (allowedTypes: string[]) => (req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback): void => {
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
const error = new ApiError(400, `File type ${file.mimetype} is not allowed`);
cb(error as unknown as null, false);
}
};
interface UploaderOptions {
fileSize?: number;
[key: string]: unknown;
}
export const uploader = (folderName: string = 'uploads', allowedTypes: string[] = allowedFileTypes, options: UploaderOptions = {}): multer.Multer => {
const {
fileSize = maxFileSize,
...otherOptions
} = options;
return multer({
storage: storage(folderName),
limits: { fileSize },
fileFilter: fileFilter(allowedTypes),
...otherOptions,
});
};

View File

@@ -0,0 +1,46 @@
import { Request, Response, NextFunction } from 'express';
import { ObjectSchema, ValidationError } from 'yup';
import ApiError from '../utils/helper/ApiError';
import { pick } from '../utils/handler/pick.handler';
/**
* Validation middleware for Express routes.
* @param schema - Validation schema object (Yup schema for request parts like params, query, body, etc.).
* @returns Middleware function to validate request properties.
*/
const validate =
(schema: Partial<Record<keyof Request, ObjectSchema<never>>>) =>
(req: Request, res: Response, next: NextFunction): void => {
// Define valid request keys explicitly
const validRequestKeys = ['params', 'query', 'body', 'file', 'files'] as (keyof Request)[];
// Extract the schemas for valid parts of the request
const validSchema = pick(schema, validRequestKeys);
// Extract the corresponding request properties
const object = pick(req, Object.keys(validSchema) as (keyof Request)[]);
// Validate each part of the request
const promises = Object.keys(validSchema).map((key) =>
validSchema[key as keyof Request]?.validate(object[key as keyof Request], { abortEarly: false })
);
// Process validation results
Promise.all(promises)
.then((validatedValues) => {
// Assign validated values back to the request object
validatedValues.forEach((value, index) => {
const key = Object.keys(validSchema)[index];
// Safely assign the validated value to the request object
req[key as keyof Request] = value; // Use `Request` here instead of `Request.ResBody`
});
next();
})
.catch((err: ValidationError) => {
// Collect and format error messages
const errorMessage = err.inner.map((detail) => detail.message).join(', ');
next(new ApiError(400, errorMessage));
});
};
export default validate;

View File

@@ -0,0 +1,41 @@
import { injectable } from 'inversify';
import { Repository } from "typeorm";
import { IamPrincipal } from "../../entities";
import { IIamPrincipalRepository } from "../../interfaces/repository"
import { AppDataSource } from "../../config/data-source";
import ApiError from '../../utils/helper/ApiError';
@injectable()
export class IamPrincipalRepository implements IIamPrincipalRepository {
private readonly iamPrincipalRepository: Repository<IamPrincipal>;
constructor() {
this.iamPrincipalRepository = AppDataSource.getRepository(IamPrincipal);
}
async create(data: Partial<IamPrincipal>): Promise<IamPrincipal> {
const principal = this.iamPrincipalRepository.create(data);
return await this.iamPrincipalRepository.save(principal);
}
async update(id: number, data: Partial<IamPrincipal>): Promise<IamPrincipal> {
const existingPrincipal = await this.iamPrincipalRepository.findOne({ where: { id } });
if (!existingPrincipal) {
throw new ApiError(404, `Principal with ID ${id} not found`);
}
await this.iamPrincipalRepository.update(id, data);
return this.iamPrincipalRepository.findOneOrFail({ where: { id } });
}
async findAll(limit: number, offset: number): Promise<IamPrincipal[]> {
return await this.iamPrincipalRepository.find({
take: limit,
skip: offset,
order: { id: "ASC" }
});
}
async findById(id: number): Promise<IamPrincipal> {
return await this.iamPrincipalRepository.findOneOrFail({ where: { id } });
}
async findByEmail(emailAddress: string): Promise<IamPrincipal> {
return await this.iamPrincipalRepository.findOneOrFail({ where: { emailAddress }, select: ['id', 'emailAddress', 'password_hash'] });
}
}

View File

@@ -0,0 +1,3 @@
export * from './IAM/iamPrincipal.repository';
export * from './product/product.repository';
export * from './token/token.repository';

View File

@@ -0,0 +1,48 @@
import { Repository } from "typeorm";
import { AppDataSource } from "../../config/data-source";
import { Product } from "../../entities/main/Product";
import ApiError from "../../utils/helper/ApiError";
import { injectable } from "inversify";
import { IProductRepository } from "../../interfaces/repository";
@injectable()
export class ProductRepository implements IProductRepository {
private readonly productRepository: Repository<Product>;
constructor() {
this.productRepository = AppDataSource.getRepository(Product);
}
// Create a new product
async create(data: Product): Promise<Product> {
const product = this.productRepository.create(data); // Prepare new product entity
return await this.productRepository.save(product); // Save to the database
}
// Update a product
async update(id: number, data: Partial<Product>): Promise<Product> {
// Check if the product exists before updating
const existingProduct = await this.productRepository.findOne({ where: { id } });
if (!existingProduct) {
throw new ApiError(404, `Product with ID ${id} not found`);
}
// Update the product and return the updated entity
await this.productRepository.update(id, data);
return this.productRepository.findOneOrFail({ where: { id } });
}
// Find products with pagination
async find(limit: number, offset: number): Promise<Product[]> {
return await this.productRepository.find({
take: limit,
skip: offset,
order: { id: "ASC" }, // Optional: Add sorting for consistent results
});
}
// Find by ID Product
async findById(id: number): Promise<Product> {
return await this.productRepository.findOneOrFail({ where: { id } });
}
}

View File

@@ -0,0 +1,34 @@
import { AppDataSource } from "../../config/data-source";
import { Token } from "../../entities";
import { ITokenRepository } from "../../interfaces/repository";
import { Repository } from "typeorm";
export class TokenRepository implements ITokenRepository {
private readonly tokenRepository: Repository<Token>;
constructor() {
this.tokenRepository = AppDataSource.getRepository(Token);
}
async saveToken(token: string, principal_xid: number, expiresAt: Date, type: string, isBlacklisted: boolean): Promise<Token> {
const newToken = this.tokenRepository.create({
token,
iamPrincipal: { id: principal_xid }, // Assuming the `iamPrincipal` relation uses `id` as the primary key
expiresAt,
type,
isBlacklisted
});
return await this.tokenRepository.save(newToken);
}
async findToken(token: string): Promise<Token | null> {
return await this.tokenRepository.findOne({ where: { token } });
}
async makeBackListToken(token: string): Promise<Token | null> {
const existingToken = await this.findToken(token);
if (!existingToken) return null;
existingToken.isBlacklisted = true;
return await this.tokenRepository.save(existingToken);
}
}

7
src/routes/auth/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import express from 'express';
const authRouter = express.Router();
import userRoutes from './user.routes';
authRouter.use('/', userRoutes);
export default authRouter;

View File

@@ -0,0 +1,24 @@
import { IamPrincipalInteractor, TokenInteractor } from '../../interactors';
import { IIamPrincipalInteractor, ITokenInteractor } from '../../interfaces/interactor';
import { Router } from 'express';
import { IIamPrincipalRepository, ITokenRepository } from '../../interfaces/repository';
import { IamPrincipalRepository, TokenRepository } from '../../repositories';
import { LoginController, RegistrationController } from '../../controllers';
const userRoutes = Router();
const tokenRepository: ITokenRepository = new TokenRepository();
const principalRepository: IIamPrincipalRepository = new IamPrincipalRepository();
const tokenInteractor: ITokenInteractor = new TokenInteractor(tokenRepository);
const iamPrincipalInteractor: IIamPrincipalInteractor = new IamPrincipalInteractor(principalRepository);
const controller = new LoginController(tokenInteractor, iamPrincipalInteractor);
const registrationController = new RegistrationController(iamPrincipalInteractor);
userRoutes.route('/login-email').post(controller.login.bind(controller));
userRoutes.route('/register').post(registrationController.registerEmail.bind(registrationController));
export default userRoutes;

83
src/routes/index.ts Normal file
View File

@@ -0,0 +1,83 @@
import express, { Router } from 'express';
import config from '../config/config';
// Define a reusable type for route definitions
interface RouteDefinition {
path: string;
route: () => Promise<Router>; // Function returning a Promise of Router
}
class Routes {
private router: Router;
private defaultRoutes: RouteDefinition[];
private devRoutes: RouteDefinition[];
constructor() {
this.router = express.Router();
this.defaultRoutes = this.initializeDefaultRoutes();
this.devRoutes = this.initializeDevRoutes();
this.setupRoutes();
}
// Initialize default routes with dynamic imports
private initializeDefaultRoutes(): RouteDefinition[] {
return [
{
path: '/products',
route: () => import('./productRoutes').then((module) => module.default)
},
{
path: '/auth',
route: () => import('./auth').then((module) => module.default)
}
];
}
// Initialize development-specific routes with dynamic imports
private initializeDevRoutes(): RouteDefinition[] {
return [
// {
// path: '/docs',
// route: () => import('./docs/docs.route').then((module) => module.default)
// }
];
}
// Register all routes
private setupRoutes(): void {
this.defaultRoutes.forEach(({ path, route }) => {
this.registerRoute(path, route);
});
this.setupEnvironmentSpecificRoutes();
}
// Register environment-specific routes
private setupEnvironmentSpecificRoutes(): void {
if (config.env === 'development') {
this.devRoutes.forEach(({ path, route }) => {
this.registerRoute(path, route);
});
}
}
// Register a single route safely with async handling
private async registerRoute(path: string, route: () => Promise<Router>): Promise<void> {
try {
const loadedRoute = await route();
this.router.use(path, loadedRoute);
} catch (error) {
console.error(`Failed to load route at path: ${path}`, error);
}
}
// Return the router instance
public getRouter(): Router {
return this.router;
}
}
// Export the initialized router
const routes = new Routes();
export default routes.getRouter();

View File

@@ -0,0 +1,41 @@
import express from 'express';
import { ProductController } from '../controllers/product/productController';
import { ProductRepository } from '../repositories/product/product.repository';
import { ProductInteractor } from '../interactors/product/productInteractor';
import { IProductInteractor } from '../interfaces/interactor/product/IProductInteractor';
import { Mailer } from '../external-libraries/mailer';
import { MessageBroker } from '../external-libraries/messageBroker';
import { Container } from 'inversify';
import { INTERFACE_TYPE } from '../utils';
import { IMailer } from '../interfaces/external-libraries/IMailer';
import { IMessageBroker } from '../interfaces/external-libraries/IMessageBroker';
import { IProductRepository } from './../interfaces/repository';
/**
const repository = new ProductRepository()
const mailer = new Mailer();
const broker = new MessageBroker()
const interactor: IProductInteractor = new ProductInteractor(repository, mailer, broker)
const productController = new ProductController(interactor);
*/
const container = new Container();
container.bind<IProductRepository>(INTERFACE_TYPE.ProductRepository).to(ProductRepository);
container.bind<IProductInteractor>(INTERFACE_TYPE.ProductInteractor).to(ProductInteractor);
container.bind<IMailer>(INTERFACE_TYPE.Mailer).to(Mailer);
container.bind<IMessageBroker>(INTERFACE_TYPE.MessageBroker).to(MessageBroker);
container.bind(INTERFACE_TYPE.ProductController).to(ProductController);
const router = express.Router();
const productController = container.get<ProductController>(INTERFACE_TYPE.ProductController);
router.post('/', productController.onCreateProduct.bind(productController));
router.get('/', productController.onGetProducts.bind(productController));
router.patch('/:id', productController.onUpdateStock.bind(productController));
router.get('/:id', productController.onGetByIDProducts.bind(productController));
export default router;

View File

@@ -0,0 +1,17 @@
export const INTERFACE_TYPE = {
ProductRepository: Symbol.for("ProductRepository"),
ProductInteractor: Symbol.for("ProductInteractor"),
ProductController: Symbol.for("ProductController"),
Mailer: Symbol.for("Mailer"),
MessageBroker: Symbol.for("MessageBroker"),
// Add more interface type here
IamPrincipalRepository: Symbol.for("IamPrincipalRepository"),
IamPrincipalInteractor: Symbol.for("IamPrincipalInteractor"),
TokenRepository: Symbol.for("TokenRepository"),
TokenInteractor: Symbol.for("TokenInteractor"),
LoginController: Symbol.for("LoginController"),
RegistrationController: Symbol.for("RegistrationController"),
}

View File

@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from 'express';
export function AsyncHandler() {
return function (
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
): void {
const originalMethod = descriptor.value;
descriptor.value = function (req: Request, res: Response, next: NextFunction) {
Promise.resolve(originalMethod.call(this, req, res, next)).catch(next);
};
};
}

View File

@@ -0,0 +1,17 @@
/**
* Create an object composed of the picked object properties.
* @function pick
*
* @param object - The source object to pick properties from.
* @param keys - The array of property names to pick from the source object.
* @returns New object with only the picked properties.
*/
export const pick = <T extends object, K extends keyof T>(object: T, keys: K[]): Pick<T, K> => {
return keys.reduce((result, key) => {
// Check if the object has the specified property
if (Object.prototype.hasOwnProperty.call(object, key)) {
result[key] = object[key]; // Assign the property to the result object
}
return result;
}, {} as Pick<T, K>); // Type the accumulator as Pick<T, K>
};

View File

@@ -0,0 +1,33 @@
class ApiError<T = unknown> extends Error {
statusCode: number;
data: T | null;
message: string;
success: boolean;
errors: Array<Error>;
isOperational: boolean;
stack?: string;
constructor(
statusCode: number,
message: string = 'Something went wrong',
errors: Array<Error> = [],
isOperational: boolean = true,
stack?: string
) {
super(message);
this.statusCode = statusCode;
this.data = null;
this.message = message;
this.success = false;
this.errors = errors;
this.isOperational = isOperational;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
export default ApiError;

View File

@@ -0,0 +1,28 @@
class ApiResponse<T> {
statusCode: number;
data: T | null;
message: string;
success: boolean;
stack?: string; // Made optional
errors?: string [] | Error[]; // Made optional
constructor(
statusCode: number,
data: T | null,
message: string = 'Success',
options: { stack?: string; errors?: string [] | Error[] } = {}
) {
this.statusCode = statusCode;
this.data = data;
this.message = message;
this.success = statusCode < 400;
// Include `stack` and `errors` only if success is false.
if (!this.success) {
this.stack = options.stack || undefined;
this.errors = options.errors || [];
}
}
}
export default ApiResponse;

5
src/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from "./handler/async.handler";
export * from "./handler/pick.handler";
export * from "./helper/ApiError";
export * from "./helper/ApiResponse"
export * from "./constant/appConstant";

48
tsconfig.json Normal file
View File

@@ -0,0 +1,48 @@
{
"compilerOptions": {
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript */
"experimentalDecorators": true, /* Enable experimental support for legacy decorators */
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations */
/* Modules */
"module": "commonjs", /* Specify the module system to use */
"esModuleInterop": true, /* Enable interop with CommonJS modules */
"forceConsistentCasingInFileNames": true, /* Ensure that file names are case-sensitive */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options */
"skipLibCheck": true, /* Skip type checking of declaration files */
/* Emit */
"sourceMap": true, /* Create source map files for emitted JavaScript */
"declaration": true, /* Generate .d.ts files from TypeScript */
"outDir": "./dist", /* Specify an output folder for emitted files */
"removeComments": true, /* Disable emitting comments in output */
/* Interop Constraints */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules without default exports */
"resolveJsonModule": true, /* Allow importing JSON files */
/* Optional Features */
"lib": ["es2016", "dom"], /* Specify the set of library files to include */
"moduleResolution": "node", /* Specify module resolution strategy (Node.js style) */
/* JavaScript Support */
"allowJs": true, /* Allow JavaScript files to be part of the project */
/* Paths and Base URL */
"baseUrl": "./", /* Set the base directory to resolve non-relative module names */
"paths": { /* Provide paths mapping for module resolution */
"*": ["node_modules/*", "src/types/*"]
}
},
"include": [
"src/**/*.ts" /* Specify the source directory */
],
"exclude": [
"node_modules", /* Exclude node_modules from being compiled */
"dist" /* Exclude dist folder to avoid compilation of output files */
]
}

3935
yarn-error.log Normal file

File diff suppressed because it is too large Load Diff

4070
yarn.lock Normal file

File diff suppressed because it is too large Load Diff