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

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";