├── logs └── server.log ├── .nvmrc ├── .npmrc ├── src ├── lib │ ├── constant │ │ ├── allowed-cors.ts │ │ ├── seed │ │ │ └── role.ts │ │ └── upload │ │ │ ├── allowed-extension.ts │ │ │ └── allowed-mimetypes.ts │ ├── string.ts │ ├── number.ts │ ├── smtp │ │ ├── types.ts │ │ ├── nodemailer.ts │ │ └── template │ │ │ └── auth.ts │ ├── upload │ │ ├── types.ts │ │ └── multer.ts │ ├── http │ │ ├── errors │ │ │ ├── 404.ts │ │ │ ├── 403.ts │ │ │ ├── 400.ts │ │ │ ├── 401.ts │ │ │ ├── 500.ts │ │ │ ├── base.ts │ │ │ └── index.ts │ │ ├── handle.ts │ │ └── response.ts │ ├── getter-object.ts │ ├── date.ts │ ├── fs │ │ ├── delete-file.ts │ │ └── read-file.ts │ ├── storage │ │ ├── index.ts │ │ ├── types.ts │ │ ├── minio.ts │ │ ├── s3.ts │ │ └── gcs.ts │ ├── async-handler.ts │ ├── query-builder │ │ ├── transform-helper.ts │ │ ├── query-helper.ts │ │ ├── types.ts │ │ ├── sqlize-query.ts │ │ └── index.ts │ ├── module │ │ ├── with-multer.ts │ │ └── with-state.ts │ ├── boolean.ts │ ├── validate.ts │ ├── types │ │ └── express │ │ │ └── index.d.ts │ ├── token │ │ └── jwt.ts │ └── swagger │ │ └── index.ts ├── app │ ├── middleware │ │ ├── with-state.ts │ │ ├── rate-limit.ts │ │ ├── error-handle.ts │ │ ├── error-validation.ts │ │ ├── authorization.ts │ │ ├── user-agent.ts │ │ ├── error-sequelize.ts │ │ └── with-permission.ts │ ├── database │ │ ├── config.ts │ │ ├── entity │ │ │ ├── role.ts │ │ │ ├── base.ts │ │ │ ├── upload.ts │ │ │ ├── session.ts │ │ │ └── user.ts │ │ ├── schema │ │ │ ├── role.ts │ │ │ ├── session.ts │ │ │ ├── upload.ts │ │ │ └── user.ts │ │ ├── migration │ │ │ ├── 20250405075558-table-role.ts │ │ │ ├── 20250405075716-table-upload.ts │ │ │ ├── 20250405080153-table-session.ts │ │ │ └── 20250405080045-table-user.ts │ │ ├── seed │ │ │ ├── 20250405080309-role.ts │ │ │ └── 20250405080441-user.ts │ │ └── connection.ts │ ├── job │ │ ├── index.ts │ │ ├── upload.ts │ │ └── session.ts │ ├── service │ │ ├── types.ts │ │ ├── role.ts │ │ ├── session.ts │ │ ├── user.ts │ │ ├── upload.ts │ │ ├── base.ts │ │ └── auth.ts │ ├── routes │ │ ├── v1.ts │ │ └── route.ts │ └── handler │ │ ├── auth.ts │ │ ├── role.ts │ │ ├── session.ts │ │ ├── user.ts │ │ └── upload.ts ├── config │ ├── hashing.ts │ ├── smtp.ts │ ├── storage.ts │ ├── env.ts │ ├── app.ts │ └── logger.ts └── main.ts ├── .dockerignore ├── public ├── static │ └── node-js.png └── swagger │ ├── schema │ ├── role.json │ ├── health.json │ ├── upload.json │ └── user.json │ └── routes │ ├── default.json │ ├── auth.json │ ├── role.json │ ├── upload.json │ └── user.json ├── tsconfig.build.json ├── .gitignore ├── prettier.config.mjs ├── .vscode └── settings.json ├── .editorconfig ├── .sequelizerc ├── eslint.config.mjs ├── .env.example ├── tsconfig.json ├── LICENSE.md ├── Dockerfile ├── script └── secret.sh ├── package.json └── README.md /logs/server.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.18.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | auto-install-peers=true 3 | -------------------------------------------------------------------------------- /src/lib/constant/allowed-cors.ts: -------------------------------------------------------------------------------- 1 | export const allowedCors = ['http://localhost:3000'] 2 | -------------------------------------------------------------------------------- /src/lib/string.ts: -------------------------------------------------------------------------------- 1 | import { cwd } from 'node:process' 2 | 3 | export const currentDir = cwd() 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | Dockerfile.dev 3 | .gitignore 4 | 5 | /node_modules 6 | /dist 7 | /coverage 8 | -------------------------------------------------------------------------------- /public/static/node-js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masb0ymas/express-api-sequelize/HEAD/public/static/node-js.png -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/number.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export function isNumeric(value: any): boolean { 4 | return !_.isNaN(parseFloat(value)) && _.isFinite(value) 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/smtp/types.ts: -------------------------------------------------------------------------------- 1 | import SMTPTransport from 'nodemailer/lib/smtp-transport' 2 | 3 | export type NodemailerParams = { 4 | transporter: SMTPTransport | SMTPTransport.Options 5 | defaults?: SMTPTransport.Options 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /coverage 4 | /public/output 5 | /public/temp 6 | /public/uploads 7 | /temp 8 | 9 | .husky 10 | 11 | package-lock.json 12 | yarn-error.log 13 | .idea/ 14 | .DS_Store 15 | 16 | .env 17 | -------------------------------------------------------------------------------- /src/lib/upload/types.ts: -------------------------------------------------------------------------------- 1 | export type MulterConfig = { 2 | dest?: string 3 | allowed_ext?: string[] 4 | allowed_mimetype?: string[] 5 | limit?: { 6 | field_size?: number 7 | file_size?: number 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/http/errors/404.ts: -------------------------------------------------------------------------------- 1 | import BaseResponse from './base' 2 | 3 | class NotFound extends BaseResponse { 4 | constructor(message: string) { 5 | super(message, 'Not Found', 404) 6 | Object.setPrototypeOf(this, NotFound.prototype) 7 | } 8 | } 9 | 10 | export default NotFound 11 | -------------------------------------------------------------------------------- /src/lib/http/errors/403.ts: -------------------------------------------------------------------------------- 1 | import BaseResponse from './base' 2 | 3 | class Forbidden extends BaseResponse { 4 | constructor(message: string) { 5 | super(message, 'Forbidden', 403) 6 | Object.setPrototypeOf(this, Forbidden.prototype) 7 | } 8 | } 9 | 10 | export default Forbidden 11 | -------------------------------------------------------------------------------- /src/lib/http/errors/400.ts: -------------------------------------------------------------------------------- 1 | import BaseResponse from './base' 2 | 3 | class BadRequest extends BaseResponse { 4 | constructor(message: string) { 5 | super(message, 'Bad Request', 400) 6 | Object.setPrototypeOf(this, BadRequest.prototype) 7 | } 8 | } 9 | 10 | export default BadRequest 11 | -------------------------------------------------------------------------------- /src/lib/http/errors/401.ts: -------------------------------------------------------------------------------- 1 | import BaseResponse from './base' 2 | 3 | class Unauthorized extends BaseResponse { 4 | constructor(message: string) { 5 | super(message, 'Unauthorized', 401) 6 | Object.setPrototypeOf(this, Unauthorized.prototype) 7 | } 8 | } 9 | 10 | export default Unauthorized 11 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/en/configuration.html 3 | * @type {import("prettier").Config} 4 | */ 5 | const config = { 6 | printWidth: 100, 7 | tabWidth: 2, 8 | semi: false, 9 | singleQuote: true, 10 | trailingComma: "es5", 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /src/lib/http/errors/500.ts: -------------------------------------------------------------------------------- 1 | import BaseResponse from './base' 2 | 3 | class InternalServer extends BaseResponse { 4 | constructor(message: string) { 5 | super(message, 'Internal Server', 500) 6 | Object.setPrototypeOf(this, InternalServer.prototype) 7 | } 8 | } 9 | 10 | export default InternalServer 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "node_modules/": true, 4 | ".next/": true, 5 | ".idea/": true 6 | }, 7 | "search.exclude": { 8 | "**/node_modules": true, 9 | "ios": true, 10 | "android": true, 11 | "vendor": true 12 | }, 13 | "git.ignoreLimitWarning": true 14 | } 15 | -------------------------------------------------------------------------------- /src/app/middleware/with-state.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import WithState from '~/lib/module/with-state' 3 | 4 | export default function expressWithState() { 5 | return function (req: Request, _res: Response, next: NextFunction) { 6 | new WithState(req) 7 | next() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | config: path.resolve('./dist/app/database', 'config.js'), 5 | 'models-path': path.resolve('./dist/app/database', 'entity'), 6 | 'seeders-path': path.resolve('./dist/app/database', 'seed'), 7 | 'migrations-path': path.resolve('./dist/app/database', 'migration'), 8 | } 9 | -------------------------------------------------------------------------------- /src/config/hashing.ts: -------------------------------------------------------------------------------- 1 | import argon2 from 'argon2' 2 | 3 | export default class Hashing { 4 | async hash(password: string): Promise { 5 | return await argon2.hash(password) 6 | } 7 | 8 | async verify(hash: string, password: string): Promise { 9 | return await argon2.verify(hash, password) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/constant/seed/role.ts: -------------------------------------------------------------------------------- 1 | const ID_SUPER_ADMIN = '238d25e6-b77d-4c5c-8a46-480fe37a4832' 2 | const ID_ADMIN = '6e9da7a5-841b-4337-ae2f-454aa91df38d' 3 | const ID_USER = '6633a9cf-cc7d-4b90-951e-69303a8e5f81' 4 | 5 | export const ConstRole = { 6 | ID_SUPER_ADMIN, 7 | ID_ADMIN, 8 | ID_USER, 9 | ROLE_ADMIN: [ID_SUPER_ADMIN, ID_ADMIN], 10 | } 11 | -------------------------------------------------------------------------------- /src/app/database/config.ts: -------------------------------------------------------------------------------- 1 | import { env } from '~/config/env' 2 | 3 | module.exports = { 4 | username: env.SEQUELIZE_USERNAME, 5 | password: env.SEQUELIZE_PASSWORD, 6 | database: env.SEQUELIZE_DATABASE, 7 | host: env.SEQUELIZE_HOST, 8 | port: env.SEQUELIZE_PORT, 9 | dialect: env.SEQUELIZE_CONNECTION, 10 | timezone: env.SEQUELIZE_TIMEZONE, 11 | } 12 | -------------------------------------------------------------------------------- /src/app/database/entity/role.ts: -------------------------------------------------------------------------------- 1 | import { Column, DeletedAt, Table } from 'sequelize-typescript' 2 | import BaseSchema from './base' 3 | 4 | @Table({ tableName: 'role', paranoid: true }) 5 | export default class Role extends BaseSchema { 6 | @DeletedAt 7 | @Column 8 | deleted_at?: Date 9 | 10 | @Column({ allowNull: false }) 11 | name: string 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/http/errors/base.ts: -------------------------------------------------------------------------------- 1 | class BaseResponse extends Error { 2 | public statusCode: number 3 | 4 | constructor(message: string, name = 'Internal Server', statusCode = 500) { 5 | super(message) 6 | this.message = message 7 | this.name = name 8 | this.statusCode = statusCode 9 | Object.setPrototypeOf(this, BaseResponse.prototype) 10 | } 11 | } 12 | 13 | export default BaseResponse 14 | -------------------------------------------------------------------------------- /src/lib/getter-object.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export default function getterObject( 4 | object: TObject | null | undefined, 5 | path?: TKey | [TKey] | string, 6 | defaultValue?: TDefault 7 | ): TObject | null | undefined { 8 | if (_.isNil(path) || path === '') { 9 | return object 10 | } 11 | 12 | return _.get(object, path, defaultValue) 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/http/errors/index.ts: -------------------------------------------------------------------------------- 1 | import BadRequest from './400' 2 | import Unauthorized from './401' 3 | import Forbidden from './403' 4 | import NotFound from './404' 5 | import InternalServer from './500' 6 | import BaseResponse from './base' 7 | 8 | const ErrorResponse = { 9 | BadRequest, 10 | BaseResponse, 11 | Forbidden, 12 | InternalServer, 13 | NotFound, 14 | Unauthorized, 15 | } 16 | 17 | export default ErrorResponse 18 | -------------------------------------------------------------------------------- /src/config/smtp.ts: -------------------------------------------------------------------------------- 1 | import Nodemailer from '~/lib/smtp/nodemailer' 2 | import { env } from './env' 3 | 4 | export const smtp = new Nodemailer({ 5 | transporter: { 6 | host: env.MAIL_HOST, 7 | port: env.MAIL_PORT, 8 | secure: env.MAIL_ENCRYPTION === 'ssl', 9 | auth: { 10 | user: env.MAIL_USERNAME, 11 | pass: env.MAIL_PASSWORD, 12 | }, 13 | }, 14 | defaults: { 15 | from: env.MAIL_FROM, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /src/app/job/index.ts: -------------------------------------------------------------------------------- 1 | import SessionJob from './session' 2 | import UploadJob from './upload' 3 | 4 | export default class Job { 5 | static initialize() { 6 | this._sessionJob() 7 | this._uploadJob() 8 | } 9 | 10 | private static _sessionJob() { 11 | const task = SessionJob.removeSession() 12 | task.start() 13 | } 14 | 15 | private static _uploadJob() { 16 | const task = UploadJob.updateSignedUrl() 17 | task.start() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/service/types.ts: -------------------------------------------------------------------------------- 1 | import { Model, ModelStatic } from 'sequelize' 2 | import { z } from 'zod' 3 | 4 | export type BaseServiceParams = { 5 | repository: ModelStatic 6 | schema: z.ZodType 7 | model: string 8 | } 9 | 10 | export type FindParams = { 11 | page: number 12 | pageSize: number 13 | filtered: any 14 | sorted: any 15 | } 16 | 17 | export type DtoFindAll = { 18 | data: T[] 19 | total: number 20 | } 21 | -------------------------------------------------------------------------------- /src/app/database/schema/role.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import BaseSchema from '../entity/base' 3 | 4 | // Schema 5 | export const roleSchema = z.object({ 6 | name: z 7 | .string('name is required') 8 | .min(3, { message: 'name must be at least 3 characters long' }) 9 | .max(255, { message: 'name must be at most 255 characters long' }), 10 | }) 11 | 12 | // Type 13 | export type RoleSchema = z.infer & 14 | BaseSchema & { 15 | deleted_at: Date | null 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/constant/upload/allowed-extension.ts: -------------------------------------------------------------------------------- 1 | // extension 2 | export const allowed_zip = ['.zip', '.7z'] 3 | export const allowed_pdf = ['.pdf'] 4 | export const allowed_image = ['.png', '.jpg', '.jpeg', '.svg', '.webp'] 5 | export const allowed_excel = ['.xlsx', '.xls'] 6 | export const allowed_doc = ['.doc', '.docx'] 7 | 8 | // default allowed ext 9 | export const default_allowed_ext = [ 10 | ...allowed_zip, 11 | ...allowed_pdf, 12 | ...allowed_image, 13 | ...allowed_excel, 14 | ...allowed_doc, 15 | ] 16 | -------------------------------------------------------------------------------- /src/config/storage.ts: -------------------------------------------------------------------------------- 1 | import Storage from '~/lib/storage' 2 | import { S3StorageParams, StorageType } from '~/lib/storage/types' 3 | import { env } from './env' 4 | 5 | export const storage = Storage.create({ 6 | storageType: env.STORAGE_PROVIDER as StorageType, 7 | params: { 8 | access_key: env.STORAGE_ACCESS_KEY, 9 | secret_key: env.STORAGE_SECRET_KEY, 10 | bucket: env.STORAGE_BUCKET_NAME, 11 | expires: env.STORAGE_SIGN_EXPIRED, 12 | region: env.STORAGE_REGION, 13 | } as S3StorageParams, 14 | }) 15 | -------------------------------------------------------------------------------- /src/lib/date.ts: -------------------------------------------------------------------------------- 1 | export function ms(value: string): number { 2 | const TIME_UNITS = { 3 | s: 1000, 4 | m: 60 * 1000, 5 | h: 60 * 60 * 1000, 6 | d: 24 * 60 * 60 * 1000, 7 | w: 7 * 24 * 60 * 60 * 1000, 8 | } 9 | 10 | const type = value.slice(-1) 11 | const numericValue = parseInt(value.slice(0, -1), 10) 12 | 13 | if (isNaN(numericValue) || !(type in TIME_UNITS)) { 14 | throw new Error('Invalid time format') 15 | } 16 | 17 | // @ts-expect-error 18 | return numericValue * TIME_UNITS[type] 19 | } 20 | -------------------------------------------------------------------------------- /public/swagger/schema/role.json: -------------------------------------------------------------------------------- 1 | { 2 | "Role": { 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "name": { 9 | "type": "string" 10 | }, 11 | "created_at": { 12 | "type": "string", 13 | "format": "date" 14 | }, 15 | "updated_at": { 16 | "type": "string", 17 | "format": "date" 18 | }, 19 | "deleted_at": { 20 | "type": "string", 21 | "format": "date" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/service/role.ts: -------------------------------------------------------------------------------- 1 | import { Model, ModelStatic } from 'sequelize' 2 | import Role from '../database/entity/role' 3 | import { roleSchema } from '../database/schema/role' 4 | import BaseService from './base' 5 | 6 | // Define a type that ensures Role is recognized as a Sequelize Model 7 | type RoleModel = Role & Model 8 | 9 | export default class RoleService extends BaseService { 10 | constructor() { 11 | super({ 12 | repository: Role as unknown as ModelStatic, 13 | schema: roleSchema, 14 | model: 'role', 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/fs/delete-file.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { logger } from '~/config/logger' 5 | 6 | const msgType = `${green('filesystem')}` 7 | 8 | /** 9 | * Delete a file from the filesystem 10 | */ 11 | export function deleteFile(filePath: string): void { 12 | const resolvedPath = path.resolve(filePath) 13 | 14 | if (fs.existsSync(resolvedPath)) { 15 | fs.unlinkSync(resolvedPath) 16 | logger.info(`${msgType} - Deleted file: ${resolvedPath}`) 17 | } else { 18 | logger.error(`${msgType} - File does not exist: ${resolvedPath}`) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/swagger/schema/health.json: -------------------------------------------------------------------------------- 1 | { 2 | "Health": { 3 | "type": "object", 4 | "properties": { 5 | "date": { 6 | "type": "string" 7 | }, 8 | "node": { 9 | "type": "string" 10 | }, 11 | "express": { 12 | "type": "string" 13 | }, 14 | "api": { 15 | "type": "string" 16 | }, 17 | "platform": { 18 | "type": "string" 19 | }, 20 | "uptime": { 21 | "type": "string" 22 | }, 23 | "cpu_usage": { 24 | "type": "string" 25 | }, 26 | "memory": { 27 | "type": "string" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/app/middleware/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import rateLimit, { Options, RateLimitRequestHandler } from 'express-rate-limit' 3 | 4 | export default function expressRateLimit(): RateLimitRequestHandler { 5 | return rateLimit({ 6 | windowMs: 15 * 60 * 1000, // 15 minutes 7 | max: 100, // 100 requests 8 | handler: (_req: Request, res: Response, _next: NextFunction, options: Options) => { 9 | const result = { 10 | statusCode: options.statusCode, 11 | error: 'Too Many Requests', 12 | message: options.message, 13 | } 14 | 15 | res.status(options.statusCode).json(result) 16 | }, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/app/database/entity/base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreatedAt, 4 | DataType, 5 | IsUUID, 6 | Model, 7 | PrimaryKey, 8 | Table, 9 | UpdatedAt, 10 | } from 'sequelize-typescript' 11 | 12 | export interface IBaseEntity { 13 | id?: string 14 | created_at: Date 15 | updated_at: Date 16 | } 17 | 18 | @Table({ tableName: 'base' }) 19 | export default class BaseSchema extends Model { 20 | @IsUUID(4) 21 | @PrimaryKey 22 | @Column({ type: DataType.UUID, defaultValue: DataType.UUIDV4 }) 23 | id: string 24 | 25 | @CreatedAt 26 | @Column({ allowNull: false }) 27 | created_at!: Date 28 | 29 | @UpdatedAt 30 | @Column({ allowNull: false }) 31 | updated_at!: Date 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/fs/read-file.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import fs from 'fs' 3 | import * as fsAsync from 'fs/promises' 4 | import { logger } from '~/config/logger' 5 | 6 | const msgType = `${green('filesystem')}` 7 | 8 | /** 9 | * Read HTML file 10 | * @param filePath - path to HTML file 11 | * @returns HTML file content 12 | */ 13 | export async function readHTMLFile( 14 | filePath: fs.PathLike | fs.promises.FileHandle 15 | ): Promise { 16 | try { 17 | return await fsAsync.readFile(filePath, 'utf-8') 18 | } catch (err) { 19 | logger.error(`${msgType} - invalid HTML file path: ${filePath}`) 20 | throw new Error(`invalid HTML file path: ${filePath}`) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/database/entity/upload.ts: -------------------------------------------------------------------------------- 1 | import { Column, DataType, DeletedAt, Table } from 'sequelize-typescript' 2 | import BaseSchema from './base' 3 | 4 | @Table({ tableName: 'upload', paranoid: true }) 5 | export default class Upload extends BaseSchema { 6 | @DeletedAt 7 | @Column 8 | deleted_at?: Date 9 | 10 | @Column({ allowNull: false }) 11 | keyfile: string 12 | 13 | @Column({ allowNull: false }) 14 | filename: string 15 | 16 | @Column({ allowNull: false }) 17 | mimetype: string 18 | 19 | @Column({ type: DataType.INTEGER, allowNull: false }) 20 | size: number 21 | 22 | @Column({ type: DataType.TEXT, allowNull: false }) 23 | signed_url: string 24 | 25 | @Column({ allowNull: false }) 26 | expiry_date_url: Date 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/storage/index.ts: -------------------------------------------------------------------------------- 1 | import GoogleCloudStorage from './gcs' 2 | import MinIOStorage from './minio' 3 | import S3Storage from './s3' 4 | import { StorageInstance, StorageParams } from './types' 5 | 6 | export default class Storage { 7 | static create({ storageType, params }: StorageParams): StorageInstance { 8 | switch (storageType) { 9 | case 's3': 10 | // @ts-expect-error 11 | return new S3Storage(params) 12 | 13 | case 'minio': 14 | // @ts-expect-error 15 | return new MinIOStorage(params) 16 | 17 | case 'gcs': 18 | // @ts-expect-error 19 | return new GoogleCloudStorage(params) 20 | 21 | default: 22 | throw new Error('Invalid storage type') 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/job/upload.ts: -------------------------------------------------------------------------------- 1 | import cronstrue from 'cronstrue' 2 | import cron from 'node-cron' 3 | import { env } from '~/config/env' 4 | import { logger } from '~/config/logger' 5 | import UploadService from '../service/upload' 6 | 7 | const service = new UploadService() 8 | 9 | export default class UploadJob { 10 | static updateSignedUrl() { 11 | let cronExpression: string 12 | 13 | if (env.NODE_ENV === 'production') { 14 | cronExpression = '*/30 * * * *' 15 | } else { 16 | cronExpression = '*/5 * * * *' 17 | } 18 | 19 | const task = cron.schedule(cronExpression, () => { 20 | service.updateSignedUrl() 21 | logger.info(`Schedule update signed url, schedule at ${cronstrue.toString(cronExpression)}`) 22 | }) 23 | 24 | return task 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/async-handler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import expressAsyncHandler from 'express-async-handler' 3 | 4 | /** 5 | * A higher-order function that wraps a given async function 6 | * with error handling support, so that any rejected promises 7 | * are caught and passed to express's built-in error handler. 8 | * 9 | * @param {function} fn - The async function to wrap. 10 | * @returns {function} - The wrapped async function. 11 | */ 12 | export const asyncHandler = ( 13 | fn: (req: Request, res: Response, next: NextFunction) => Promise 14 | ) => { 15 | return expressAsyncHandler( 16 | async (req: Request, res: Response, next: NextFunction): Promise => { 17 | await fn(req, res, next) 18 | return 19 | } 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/job/session.ts: -------------------------------------------------------------------------------- 1 | import cronstrue from 'cronstrue' 2 | import cron from 'node-cron' 3 | import { env } from '~/config/env' 4 | import { logger } from '~/config/logger' 5 | import SessionService from '../service/session' 6 | 7 | const service = new SessionService() 8 | 9 | export default class SessionJob { 10 | static removeSession() { 11 | let cronExpression: string 12 | 13 | if (env.NODE_ENV === 'production') { 14 | cronExpression = '*/30 * * * *' 15 | } else { 16 | cronExpression = '*/5 * * * *' 17 | } 18 | 19 | const task = cron.schedule(cronExpression, () => { 20 | service.deleteExpiredSession() 21 | logger.info(`Schedule remove session, schedule at ${cronstrue.toString(cronExpression)}`) 22 | }) 23 | 24 | return task 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tseslint from 'typescript-eslint' 3 | import eslintConfigPrettier from 'eslint-config-prettier' 4 | 5 | export default tseslint.config( 6 | eslint.configs.recommended, 7 | eslintConfigPrettier, 8 | ...tseslint.configs.recommended, 9 | ...tseslint.configs.stylistic, 10 | { 11 | rules: { 12 | '@typescript-eslint/prefer-for-of': 'off', 13 | '@typescript-eslint/ban-ts-comment': 'off', 14 | '@typescript-eslint/no-explicit-any': 'warn', 15 | '@typescript-eslint/no-unused-vars': 'warn', 16 | '@typescript-eslint/consistent-type-definitions': 'off', 17 | '@typescript-eslint/no-require-imports': 'off', 18 | '@typescript-eslint/no-var-requires': 'off', 19 | }, 20 | ignores: ['.dist/*'], 21 | } 22 | ) 23 | -------------------------------------------------------------------------------- /src/app/database/schema/session.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import BaseSchema from '../entity/base' 3 | 4 | // Schema 5 | export const sessionSchema = z.object({ 6 | user_id: z.uuid({ message: 'user_id must be a valid UUID' }), 7 | token: z 8 | .string('token is required') 9 | .min(3, { error: 'token must be at least 3 characters long' }), 10 | ip_address: z.string('ip_address is required').nullable().optional(), 11 | device: z.string('device is required').nullable().optional(), 12 | platform: z.string('platform is required').nullable().optional(), 13 | user_agent: z.string('user_agent is required').nullable().optional(), 14 | latitude: z.string('latitude is required').nullable().optional(), 15 | longitude: z.string('longitude is required').nullable().optional(), 16 | }) 17 | 18 | // Type 19 | export type SessionSchema = z.infer & BaseSchema 20 | -------------------------------------------------------------------------------- /public/swagger/schema/upload.json: -------------------------------------------------------------------------------- 1 | { 2 | "Upload": { 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "keyfile": { 9 | "type": "string" 10 | }, 11 | "filename": { 12 | "type": "string" 13 | }, 14 | "mimetype": { 15 | "type": "string" 16 | }, 17 | "size": { 18 | "type": "string" 19 | }, 20 | "signed_url": { 21 | "type": "string" 22 | }, 23 | "expiry_date_url": { 24 | "type": "string" 25 | }, 26 | "created_at": { 27 | "type": "string", 28 | "format": "date" 29 | }, 30 | "updated_at": { 31 | "type": "string", 32 | "format": "date" 33 | }, 34 | "deleted_at": { 35 | "type": "string", 36 | "format": "date" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/query-builder/transform-helper.ts: -------------------------------------------------------------------------------- 1 | export class TransformHelper { 2 | private value: T | undefined 3 | 4 | constructor(initialValue: T) { 5 | this.setValue(initialValue) 6 | } 7 | 8 | /** 9 | * Sets the internal value 10 | * @param value - The value to store 11 | */ 12 | public setValue(value: T): void { 13 | this.value = value 14 | } 15 | 16 | /** 17 | * Retrieves the stored value 18 | * @returns The current value 19 | */ 20 | public getValue(): T | undefined { 21 | return this.value 22 | } 23 | 24 | /** 25 | * Applies a transformation function to the current value 26 | * @param transformFn - Function to transform the current value 27 | * @returns This instance for chaining 28 | */ 29 | public transform(transformFn: (value: T | undefined) => T): this { 30 | this.value = transformFn(this.value) 31 | return this 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public/swagger/routes/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "/": { 3 | "get": { 4 | "tags": ["Default"], 5 | "summary": "Default Route", 6 | "security": [ 7 | { 8 | "auth_token": [] 9 | } 10 | ], 11 | "responses": { 12 | "200": { "description": "Find all records" }, 13 | "400": { "description": "Something went wrong" }, 14 | "500": { "description": "Internal Server Error" } 15 | } 16 | } 17 | }, 18 | "/health": { 19 | "get": { 20 | "tags": ["Default"], 21 | "summary": "Default Route", 22 | "security": [ 23 | { 24 | "auth_token": [] 25 | } 26 | ], 27 | "responses": { 28 | "200": { "description": "Find all records" }, 29 | "400": { "description": "Something went wrong" }, 30 | "500": { "description": "Internal Server Error" } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/module/with-multer.ts: -------------------------------------------------------------------------------- 1 | import { type Request } from 'express' 2 | import _ from 'lodash' 3 | 4 | /** 5 | * 6 | * @param req 7 | * @param fields 8 | * @returns 9 | */ 10 | export function pickSingleFieldMulter(req: Request, fields: string[]): Partial { 11 | return _.pickBy( 12 | fields.reduce((acc, field) => { 13 | acc[field] = req.getSingleArrayFile(field) 14 | return acc 15 | }, {}), 16 | (value) => { 17 | return value !== undefined 18 | } 19 | ) 20 | } 21 | 22 | /** 23 | * 24 | * @param req 25 | * @param fields 26 | * @returns 27 | */ 28 | export function pickMultiFieldMulter(req: Request, fields: string[]): Partial { 29 | return _.pickBy( 30 | fields.reduce((acc, field) => { 31 | acc[field] = req.getMultiArrayFile(field) 32 | return acc 33 | }, {}), 34 | (value) => { 35 | return value !== undefined 36 | } 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/boolean.ts: -------------------------------------------------------------------------------- 1 | import { env } from '~/config/env' 2 | 3 | /** 4 | * Check if storage is enabled 5 | */ 6 | export function storageExists(): boolean { 7 | switch (env.STORAGE_PROVIDER) { 8 | case 'minio': 9 | return Boolean( 10 | env.STORAGE_HOST && 11 | env.STORAGE_BUCKET_NAME && 12 | env.STORAGE_ACCESS_KEY && 13 | env.STORAGE_SECRET_KEY 14 | ) 15 | 16 | case 's3': 17 | return Boolean(env.STORAGE_BUCKET_NAME && env.STORAGE_ACCESS_KEY && env.STORAGE_SECRET_KEY) 18 | 19 | case 'gcs': 20 | return Boolean(env.STORAGE_ACCESS_KEY && env.STORAGE_BUCKET_NAME && env.STORAGE_FILEPATH) 21 | 22 | default: 23 | return false 24 | } 25 | } 26 | 27 | /** 28 | * Check if mail is enabled 29 | */ 30 | export function mailExists(): boolean { 31 | return Boolean( 32 | env.MAIL_HOST && env.MAIL_PORT && env.MAIL_USERNAME && env.MAIL_PASSWORD && env.MAIL_FROM 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | APP_NAME=Backend 4 | APP_PORT=8000 5 | APP_URL=http://localhost:8000 6 | APP_SECRET=yoursecret 7 | APP_DEFAULT_PASS=yourpassword 8 | 9 | SEQUELIZE_CONNECTION=postgres 10 | SEQUELIZE_HOST=127.0.0.1 11 | SEQUELIZE_PORT=5432 12 | SEQUELIZE_DATABASE=postgres 13 | SEQUELIZE_USERNAME=postgres 14 | SEQUELIZE_PASSWORD=postgres 15 | SEQUELIZE_SYNC=false 16 | SEQUELIZE_LOGGING=true 17 | SEQUELIZE_TIMEZONE=Asia/Jakarta 18 | 19 | STORAGE_PROVIDER=gcs # s3, minio, gcs 20 | STORAGE_HOST= # for Minio 21 | STORAGE_PORT= # for Minio 22 | STORAGE_ACCESS_KEY= 23 | STORAGE_SECRET_KEY= 24 | STORAGE_BUCKET_NAME=expresso 25 | STORAGE_REGION=ap-southeast-1 26 | STORAGE_SIGN_EXPIRED=7d 27 | STORAGE_FILEPATH= # for Google Cloud Storage 28 | 29 | JWT_SECRET=yoursecret 30 | JWT_EXPIRES=7d 31 | 32 | MAIL_DRIVER=smtp 33 | MAIL_HOST=smtp.mailtrap.io 34 | MAIL_PORT=587 35 | MAIL_FROM= 36 | MAIL_USERNAME= 37 | MAIL_PASSWORD= 38 | MAIL_ENCRYPTION= 39 | -------------------------------------------------------------------------------- /src/app/database/migration/20250405075558-table-role.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { DataTypes, QueryInterface } from 'sequelize' 4 | 5 | /** @type {import('sequelize-cli').Migration} */ 6 | export async function up(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 7 | await queryInterface.createTable('role', { 8 | id: { 9 | allowNull: false, 10 | primaryKey: true, 11 | type: Sequelize.UUID, 12 | defaultValue: Sequelize.UUIDV4, 13 | }, 14 | created_at: { 15 | allowNull: false, 16 | type: Sequelize.DATE, 17 | }, 18 | updated_at: { 19 | allowNull: false, 20 | type: Sequelize.DATE, 21 | }, 22 | deleted_at: { 23 | type: Sequelize.DATE, 24 | }, 25 | name: { 26 | allowNull: false, 27 | type: Sequelize.STRING, 28 | }, 29 | }) 30 | } 31 | 32 | export async function down(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 33 | await queryInterface.dropTable('role') 34 | } 35 | -------------------------------------------------------------------------------- /src/app/database/schema/upload.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import BaseSchema from '../entity/base' 3 | 4 | // Schema 5 | export const uploadSchema = z.object({ 6 | keyfile: z 7 | .string('keyfile is required') 8 | .min(3, { message: 'keyfile must be at least 3 characters long' }), 9 | filename: z 10 | .string('filename is required') 11 | .min(3, { message: 'filename must be at least 3 characters long' }), 12 | mimetype: z 13 | .string('mimetype is required') 14 | .min(3, { message: 'mimetype must be at least 3 characters long' }), 15 | size: z.number('size is required').min(1, { message: 'size must be at least 1' }), 16 | signed_url: z 17 | .string('signed_url is required') 18 | .min(3, { message: 'signed_url must be at least 3 characters long' }), 19 | expiry_date_url: z.date('expiry_date_url is required'), 20 | }) 21 | 22 | // Type 23 | export type UploadSchema = z.infer & 24 | Partial & { 25 | deleted_at?: Date | null 26 | } 27 | -------------------------------------------------------------------------------- /src/app/database/entity/session.ts: -------------------------------------------------------------------------------- 1 | import { BelongsTo, Column, DataType, ForeignKey, IsUUID, Table } from 'sequelize-typescript' 2 | import User from './user' 3 | import BaseSchema from './base' 4 | 5 | @Table({ tableName: 'session' }) 6 | export default class Session extends BaseSchema { 7 | @IsUUID(4) 8 | @ForeignKey(() => User) 9 | @Column({ 10 | type: DataType.UUID, 11 | defaultValue: DataType.UUIDV4, 12 | allowNull: false, 13 | }) 14 | user_id: string 15 | 16 | @BelongsTo(() => User) 17 | user: User 18 | 19 | @Column({ type: DataType.TEXT, allowNull: false }) 20 | token: string 21 | 22 | @Column({ allowNull: true }) 23 | ip_address?: string 24 | 25 | @Column({ allowNull: true }) 26 | device?: string 27 | 28 | @Column({ allowNull: true }) 29 | platform?: string 30 | 31 | @Column({ type: DataType.TEXT, allowNull: true }) 32 | user_agent?: string 33 | 34 | @Column({ allowNull: true }) 35 | latitude?: string 36 | 37 | @Column({ allowNull: true }) 38 | longitude?: string 39 | } 40 | -------------------------------------------------------------------------------- /src/app/middleware/error-handle.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import _ from 'lodash' 3 | import multer from 'multer' 4 | import ErrorResponse from '~/lib/http/errors' 5 | 6 | interface DtoErrorResponse { 7 | statusCode: number 8 | error: string 9 | message: string 10 | } 11 | 12 | function generateErrorResponse(err: Error, statusCode: number): DtoErrorResponse { 13 | return _.isObject(err.message) 14 | ? err.message 15 | : { statusCode, error: err.name, message: err.message } 16 | } 17 | 18 | export default async function expressErrorHandle( 19 | err: any, 20 | _req: Request, 21 | res: Response, 22 | next: NextFunction 23 | ) { 24 | // catch error from multer 25 | if (err instanceof multer.MulterError) { 26 | return res.status(400).json(generateErrorResponse(err, 400)) 27 | } 28 | 29 | // catch from global error 30 | if (err instanceof ErrorResponse.BaseResponse) { 31 | return res.status(err.statusCode).json(generateErrorResponse(err, err.statusCode)) 32 | } 33 | 34 | next(err) 35 | } 36 | -------------------------------------------------------------------------------- /src/app/middleware/error-validation.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import { NextFunction, Request, Response } from 'express' 3 | import { z } from 'zod' 4 | import { logger } from '~/config/logger' 5 | 6 | export default async function expressErrorValidation( 7 | err: any, 8 | _req: Request, 9 | res: Response, 10 | next: NextFunction 11 | ) { 12 | if (err instanceof z.ZodError) { 13 | const msgType = green('zod') 14 | const message = 'validation error!' 15 | 16 | logger.error(`${msgType} - ${message}`) 17 | 18 | const errors = 19 | err.issues.length > 0 20 | ? err.issues.reduce((acc: any, curVal: any) => { 21 | acc[`${curVal.path}`] = curVal.message || curVal.type 22 | return acc 23 | }, {}) 24 | : { [`${err.issues[0].path}`]: err.issues[0].message } 25 | 26 | const result = { 27 | statusCode: 422, 28 | error: 'Unprocessable Content', 29 | message, 30 | errors, 31 | } 32 | 33 | return res.status(422).json(result) 34 | } 35 | 36 | next(err) 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "esnext", 5 | "lib": ["es5", "es6"], 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | 9 | /* Modules */ 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "baseUrl": "./", 13 | "paths": { 14 | "~/*": ["./src/*"] 15 | }, 16 | "typeRoots": ["./src/lib/types"], 17 | 18 | /* JavaScript Support */ 19 | "allowJs": true, 20 | 21 | /* Emit */ 22 | "outDir": "./dist", 23 | "downlevelIteration": true, 24 | 25 | /* Interop Constraints */ 26 | "esModuleInterop": true, 27 | "forceConsistentCasingInFileNames": true, 28 | 29 | /* Type Checking */ 30 | "strict": true, 31 | "strictPropertyInitialization": false, 32 | "useDefineForClassFields": false, 33 | 34 | /* Completeness */ 35 | "skipLibCheck": true 36 | }, 37 | "tsc-alias": { 38 | "verbose": false, 39 | "resolveFullPaths": true 40 | }, 41 | "include": ["src/**/*"], 42 | "exclude": ["dist", "node_modules"] 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 masb0ymas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/swagger/schema/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "User": { 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "fullname": { 9 | "type": "string" 10 | }, 11 | "email": { 12 | "type": "string" 13 | }, 14 | "password": { 15 | "type": "string" 16 | }, 17 | "phone": { 18 | "type": "string" 19 | }, 20 | "token_verify": { 21 | "type": "string" 22 | }, 23 | "address": { 24 | "type": "string" 25 | }, 26 | "is_active": { 27 | "type": "string" 28 | }, 29 | "is_blocked": { 30 | "type": "string" 31 | }, 32 | "role_id": { 33 | "type": "string" 34 | }, 35 | "upload_id": { 36 | "type": "string" 37 | }, 38 | "created_at": { 39 | "type": "string", 40 | "format": "date" 41 | }, 42 | "updated_at": { 43 | "type": "string", 44 | "format": "date" 45 | }, 46 | "deleted_at": { 47 | "type": "string", 48 | "format": "date" 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/database/seed/20250405080309-role.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { isEmpty } from 'lodash' 4 | import { DataTypes, QueryInterface } from 'sequelize' 5 | import { ConstRole } from '~/lib/constant/seed/role' 6 | 7 | const data = [ 8 | { 9 | id: ConstRole.ID_SUPER_ADMIN, 10 | name: 'Super Admin', 11 | }, 12 | { 13 | id: ConstRole.ID_ADMIN, 14 | name: 'Admin', 15 | }, 16 | { 17 | id: ConstRole.ID_USER, 18 | name: 'User', 19 | }, 20 | ] 21 | 22 | /** @type {import('sequelize-cli').Migration} */ 23 | export async function up(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 24 | const formData: any[] = [] 25 | 26 | if (!isEmpty(data)) { 27 | for (let i = 0; i < data.length; i += 1) { 28 | const item = data[i] 29 | 30 | formData.push({ 31 | ...item, 32 | created_at: new Date(), 33 | updated_at: new Date(), 34 | }) 35 | } 36 | } 37 | 38 | await queryInterface.bulkInsert('role', formData) 39 | } 40 | 41 | export async function down(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 42 | await queryInterface.bulkDelete('role', {}) 43 | } 44 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { initDatabase } from './app/database/connection' 3 | import Job from './app/job' 4 | import { App } from './config/app' 5 | import { env } from './config/env' 6 | import { smtp } from './config/smtp' 7 | import { storage } from './config/storage' 8 | import { mailExists, storageExists } from './lib/boolean' 9 | import { httpHandle } from './lib/http/handle' 10 | 11 | function bootstrap() { 12 | const port = env.APP_PORT 13 | const app = new App().create 14 | const server = http.createServer(app) 15 | const isStorageEnabled = storageExists() 16 | const isMailEnabled = mailExists() 17 | 18 | // initial database 19 | initDatabase() 20 | 21 | // initial storage 22 | if (isStorageEnabled) { 23 | storage.initialize() 24 | } 25 | 26 | // initial smtp 27 | if (isMailEnabled) { 28 | smtp.initialize() 29 | } 30 | 31 | // initial job 32 | Job.initialize() 33 | 34 | // http handle 35 | const { onError, onListening } = httpHandle(server, port) 36 | 37 | // run server listen 38 | server.listen(port) 39 | server.on('error', onError) 40 | server.on('listening', onListening) 41 | } 42 | 43 | bootstrap() 44 | -------------------------------------------------------------------------------- /src/app/routes/v1.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express' 2 | import swaggerUI from 'swagger-ui-express' 3 | import { env } from '~/config/env' 4 | import { optionsSwaggerUI, swaggerSpec } from '~/lib/swagger' 5 | import { AuthHandler } from '../handler/auth' 6 | import { RoleHandler } from '../handler/role' 7 | import { SessionHandler } from '../handler/session' 8 | import { UploadHandler } from '../handler/upload' 9 | import { UserHandler } from '../handler/user' 10 | 11 | const route = express.Router() 12 | 13 | function docsSwagger() { 14 | route.get('/swagger.json', (_req: Request, res: Response) => { 15 | res.setHeader('Content-Type', 'application/json') 16 | res.send(swaggerSpec) 17 | }) 18 | 19 | route.use('/api-docs', swaggerUI.serve) 20 | route.get('/api-docs', swaggerUI.setup(swaggerSpec, optionsSwaggerUI)) 21 | } 22 | 23 | // docs swagger disable for production mode 24 | if (env.NODE_ENV !== 'production') { 25 | docsSwagger() 26 | } 27 | 28 | route.use('/role', RoleHandler) 29 | route.use('/session', SessionHandler) 30 | route.use('/upload', UploadHandler) 31 | route.use('/user', UserHandler) 32 | route.use('/auth', AuthHandler) 33 | 34 | export { route as v1Route } 35 | -------------------------------------------------------------------------------- /src/lib/storage/types.ts: -------------------------------------------------------------------------------- 1 | import GoogleCloudStorage from './gcs' 2 | import MinIOStorage from './minio' 3 | import S3Storage from './s3' 4 | 5 | export type UploadFileParams = { 6 | directory: string 7 | file: FileParams 8 | } 9 | 10 | export type FileParams = { 11 | fieldname: string 12 | originalname: string 13 | encoding: string 14 | mimetype: string 15 | destination: string 16 | filename: string 17 | path: string 18 | size: number 19 | } 20 | 21 | export type GoogleCloudStorageParams = { 22 | access_key: string 23 | bucket: string 24 | expires: string 25 | filepath: string 26 | } 27 | 28 | export type S3StorageParams = { 29 | access_key: string 30 | secret_key: string 31 | bucket: string 32 | expires: string 33 | region: string 34 | } 35 | 36 | export type MinIOStorageParams = { 37 | access_key: string 38 | secret_key: string 39 | bucket: string 40 | expires: string 41 | region: string 42 | host: string 43 | port: number 44 | ssl: boolean 45 | } 46 | 47 | export type StorageType = 's3' | 'minio' | 'gcs' 48 | 49 | export type StorageParams = { 50 | storageType: StorageType 51 | params: S3StorageParams | MinIOStorageParams | GoogleCloudStorageParams 52 | } 53 | 54 | export type StorageInstance = S3Storage | MinIOStorage | GoogleCloudStorage 55 | -------------------------------------------------------------------------------- /src/lib/validate.ts: -------------------------------------------------------------------------------- 1 | import { isValid } from 'date-fns' 2 | import { isNumeric } from './number' 3 | import { validate as uuidValidate } from 'uuid' 4 | import ErrorResponse from './http/errors' 5 | 6 | const emptyValues = [null, undefined, '', 'null', 'undefined'] 7 | const invalidValues = [...emptyValues, false, 0, 'false', '0'] 8 | 9 | export class validate { 10 | public static number(value: any) { 11 | if (isNumeric(Number(value))) { 12 | return Number(value) 13 | } 14 | 15 | return 0 16 | } 17 | 18 | public static empty(value: any): any { 19 | if (emptyValues.includes(value)) { 20 | return null 21 | } 22 | 23 | return value 24 | } 25 | 26 | public static boolean(value: any): boolean { 27 | if (invalidValues.includes(value)) { 28 | return false 29 | } 30 | 31 | return true 32 | } 33 | 34 | public static isDate(value: string | number | Date | null): boolean { 35 | if (value == null) { 36 | return false 37 | } 38 | 39 | const valueDate = value instanceof Date ? value : new Date(value) 40 | return isValid(valueDate) 41 | } 42 | 43 | public static uuid(value: string): string { 44 | if (!uuidValidate(value)) { 45 | throw new ErrorResponse.BadRequest('Invalid UUID') 46 | } 47 | 48 | return value 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/database/connection.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { Sequelize, type SequelizeOptions } from 'sequelize-typescript' 4 | import { env } from '~/config/env' 5 | import { logger } from '~/config/logger' 6 | 7 | type ConnectionType = 'postgres' | 'mysql' 8 | 9 | const sequelizeOptions: SequelizeOptions = { 10 | dialect: env.SEQUELIZE_CONNECTION as ConnectionType, 11 | host: env.SEQUELIZE_HOST, 12 | port: env.SEQUELIZE_PORT, 13 | username: env.SEQUELIZE_USERNAME, 14 | password: env.SEQUELIZE_PASSWORD, 15 | database: env.SEQUELIZE_DATABASE, 16 | logQueryParameters: env.SEQUELIZE_LOGGING, 17 | timezone: env.SEQUELIZE_TIMEZONE, 18 | models: [`${__dirname}/entity`], 19 | } 20 | 21 | const sequelize = new Sequelize({ ...sequelizeOptions }) 22 | export const db = { sequelize } 23 | 24 | export const initDatabase = async () => { 25 | try { 26 | await sequelize.authenticate() 27 | logger.info(`Database connection established: ${sequelize.options.database}`) 28 | 29 | // not recommended when running in production mode 30 | if (env.SEQUELIZE_SYNC) { 31 | await sequelize.sync({ force: true }) 32 | logger.info(`Sync database successfully`) 33 | } 34 | } catch (error: any) { 35 | logger.error(`Failed to initialize database: ${error.message}`) 36 | process.exit(1) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/middleware/authorization.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { env } from '~/config/env' 3 | import { asyncHandler } from '~/lib/async-handler' 4 | import JwtToken from '~/lib/token/jwt' 5 | import SessionService from '../service/session' 6 | import _ from 'lodash' 7 | 8 | const jwt = new JwtToken({ secret: env.JWT_SECRET, expires: env.JWT_EXPIRES }) 9 | const sessionService = new SessionService() 10 | 11 | export default function authorization() { 12 | return asyncHandler(async (req: Request, res: Response, next: NextFunction) => { 13 | const token = jwt.extract(req) 14 | if (!token) { 15 | return res.status(401).json({ 16 | statusCode: 401, 17 | error: 'Unauthorized', 18 | message: 'Unauthorized, cannot extract token from request', 19 | }) 20 | } 21 | 22 | const decoded = jwt.verify(token) 23 | if (!decoded.data) { 24 | return res.status(401).json({ 25 | statusCode: 401, 26 | error: 'Unauthorized', 27 | message: 'Unauthorized, invalid jwt', 28 | }) 29 | } 30 | 31 | const session = await sessionService.findByUserToken({ 32 | user_id: _.get(decoded, 'data.uid', ''), 33 | token, 34 | }) 35 | 36 | req.setState({ userLoginState: decoded.data, session }) 37 | next() 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/app/middleware/user-agent.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { Details } from 'express-useragent' 3 | 4 | export type UserAgentState = { 5 | browser: string 6 | version: string 7 | os: string 8 | platform: string 9 | geoIp: string 10 | source: string 11 | is: string[] 12 | device: string 13 | } 14 | 15 | export default function expressUserAgent() { 16 | return (req: Request, _res: Response, next: NextFunction) => { 17 | // check is user agent 18 | const userAgentIs = (useragent: Details | any): string[] => { 19 | const r = [] 20 | for (const i in useragent) if (useragent[i] === true) r.push(i) 21 | return r 22 | } 23 | 24 | // set user agent 25 | const userAgentState = { 26 | browser: req.useragent?.browser, 27 | version: req.useragent?.version, 28 | device: `${req.useragent?.platform} ${req.useragent?.os} - ${req.useragent?.browser} ${req.useragent?.version}`, 29 | os: req.useragent?.os, 30 | platform: req.useragent?.platform, 31 | geoIp: req.useragent?.geoIp, 32 | source: req.useragent?.source, 33 | is: userAgentIs(req.useragent), 34 | } 35 | 36 | // set client ip 37 | const clientIp = req.clientIp?.replace(/\s/g, '').replace(/::1|::ffff:/g, '127.0.0.1') 38 | 39 | req.setState({ userAgent: userAgentState, clientIp }) 40 | next() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/constant/upload/allowed-mimetypes.ts: -------------------------------------------------------------------------------- 1 | export class Mimetype { 2 | /** 3 | * Allowed Mimetype Zip 4 | * @returns 5 | */ 6 | public get zip(): string[] { 7 | return ['application/zip', 'application/x-zip-compressed', 'application/x-7z-compressed'] 8 | } 9 | 10 | /** 11 | * Allowed Mimetype PDF 12 | * @returns 13 | */ 14 | public get pdf(): string[] { 15 | return ['application/pdf'] 16 | } 17 | 18 | /** 19 | * Allowed Mimetype Image 20 | * @returns 21 | */ 22 | public get image(): string[] { 23 | return ['image/jpeg', 'image/png', 'image/svg+xml', 'image/webp'] 24 | } 25 | 26 | /** 27 | * Allowed Mimetype Spreadsheet 28 | * @returns 29 | */ 30 | public get spreadsheet(): string[] { 31 | return [ 32 | 'application/vnd.ms-excel', 33 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 34 | ] 35 | } 36 | 37 | /** 38 | * Allowed Mimetype Docs 39 | * @returns 40 | */ 41 | public get docs(): string[] { 42 | return [ 43 | 'application/msword', 44 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 45 | ] 46 | } 47 | 48 | /** 49 | * Default Return Mimetype 50 | */ 51 | public get default(): string[] { 52 | const result = [...this.docs, ...this.image, ...this.pdf, ...this.spreadsheet, ...this.zip] 53 | 54 | return result 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/database/migration/20250405075716-table-upload.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { DataTypes, QueryInterface } from 'sequelize' 4 | 5 | /** @type {import('sequelize-cli').Migration} */ 6 | export async function up(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 7 | await queryInterface.createTable('upload', { 8 | id: { 9 | allowNull: false, 10 | primaryKey: true, 11 | type: Sequelize.UUID, 12 | defaultValue: Sequelize.UUIDV4, 13 | }, 14 | created_at: { 15 | allowNull: false, 16 | type: Sequelize.DATE, 17 | }, 18 | updated_at: { 19 | allowNull: false, 20 | type: Sequelize.DATE, 21 | }, 22 | deleted_at: { 23 | type: Sequelize.DATE, 24 | }, 25 | keyfile: { 26 | allowNull: false, 27 | type: Sequelize.STRING, 28 | }, 29 | filename: { 30 | allowNull: false, 31 | type: Sequelize.STRING, 32 | }, 33 | mimetype: { 34 | allowNull: false, 35 | type: Sequelize.STRING, 36 | }, 37 | size: { 38 | allowNull: false, 39 | type: Sequelize.INTEGER, 40 | }, 41 | signed_url: { 42 | allowNull: false, 43 | type: Sequelize.TEXT, 44 | }, 45 | expiry_date_url: { 46 | allowNull: false, 47 | type: Sequelize.DATE, 48 | }, 49 | }) 50 | } 51 | 52 | export async function down(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 53 | await queryInterface.dropTable('upload') 54 | } 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | 6 | # Set the Temp Working Directory inside the container 7 | WORKDIR /temp-deps 8 | 9 | RUN npm install -g pnpm@latest-10 10 | 11 | # copy package json 12 | COPY ["package.json", "pnpm-lock.yaml", "./"] 13 | RUN pnpm install --frozen-lockfile 14 | 15 | FROM base AS builder 16 | 17 | # Set the Temp Working Directory inside the container 18 | WORKDIR /temp-build 19 | 20 | # copy base code 21 | COPY . . 22 | 23 | # copy environment 24 | RUN cp .env.example .env 25 | COPY --from=deps /temp-deps/node_modules ./node_modules 26 | 27 | RUN npm install -g pnpm@latest-10 28 | 29 | # prune devDependencies 30 | RUN pnpm build && pnpm install --production --ignore-scripts --prefer-offline 31 | 32 | # image runner app 33 | FROM base AS runner 34 | 35 | # Set the Current Working Directory inside the container 36 | WORKDIR /app 37 | 38 | ENV NODE_ENV=production 39 | 40 | # editor cli with nano 41 | RUN apk add nano 42 | 43 | COPY --from=builder /temp-build/public ./public 44 | COPY --from=builder /temp-build/node_modules ./node_modules 45 | COPY --from=builder /temp-build/package.json ./package.json 46 | COPY --from=builder /temp-build/script ./script 47 | COPY --from=builder /temp-build/logs ./logs 48 | COPY --from=builder /temp-build/dist ./dist 49 | COPY --from=builder /temp-build/.env ./.env 50 | 51 | # This container exposes port 8000 to the outside world 52 | EXPOSE 8000 53 | 54 | # Run for production 55 | CMD ["yarn", "start:production"] 56 | -------------------------------------------------------------------------------- /src/app/middleware/error-sequelize.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import { NextFunction, Request, Response } from 'express' 3 | import _ from 'lodash' 4 | import { BaseError, EmptyResultError, ValidationError } from 'sequelize' 5 | import { logger } from '~/config/logger' 6 | 7 | export default async function expressErrorSequelize( 8 | err: Error, 9 | _req: Request, 10 | res: Response, 11 | next: NextFunction 12 | ) { 13 | if (err instanceof BaseError) { 14 | const msgType = green('sequelize') 15 | logger.error(`${msgType} - err, ${err.message ?? err}`) 16 | 17 | if (err instanceof EmptyResultError) { 18 | return res.status(404).json({ 19 | code: 404, 20 | error: 'Not Found', 21 | message: `${msgType} ${err.message}`, 22 | }) 23 | } 24 | 25 | if (err instanceof ValidationError) { 26 | const errors: any[] = _.get(err, 'errors', []) 27 | const errorMessage = _.get(errors, '0.message', null) 28 | 29 | const dataError = { 30 | code: 400, 31 | message: errorMessage ? `Validation error: ${errorMessage}` : err.message, 32 | errors: errors.reduce((acc, curVal) => { 33 | acc[curVal.path] = curVal.message 34 | return acc 35 | }, {}), 36 | } 37 | 38 | return res.status(400).json(dataError) 39 | } 40 | 41 | return res.status(500).json({ 42 | code: 500, 43 | error: 'Internal Server Error', 44 | message: `${msgType} ${err.message}`, 45 | }) 46 | } 47 | 48 | next(err) 49 | } 50 | -------------------------------------------------------------------------------- /src/app/routes/route.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express' 2 | import { asyncHandler } from '~/lib/async-handler' 3 | import HttpResponse from '~/lib/http/response' 4 | import { currentDir } from '~/lib/string' 5 | import { v1Route } from './v1' 6 | 7 | const route = express.Router() 8 | 9 | function versioning() { 10 | const node_modules = `${currentDir}/node_modules` 11 | const express = require(`${node_modules}/express/package.json`).version 12 | const app = require(`${currentDir}/package.json`).version 13 | 14 | return { express: `v${express}`, app: `v${app}` } 15 | } 16 | 17 | route.get( 18 | '/', 19 | asyncHandler(async (_req: Request, res: Response) => { 20 | const httpResponse = HttpResponse.get({ data: 'Hello World!' }) 21 | res.status(200).json(httpResponse) 22 | }) 23 | ) 24 | 25 | route.get( 26 | '/health', 27 | asyncHandler(async (_req: Request, res: Response) => { 28 | const startUsage = process.cpuUsage() 29 | const version = versioning() 30 | 31 | const status = { 32 | date: new Date().toISOString(), 33 | node: process.version, 34 | express: version.express, 35 | api: version.app, 36 | platform: process.platform, 37 | uptime: process.uptime(), 38 | cpu_usage: process.cpuUsage(startUsage), 39 | memory: process.memoryUsage(), 40 | } 41 | 42 | const httpResponse = HttpResponse.get({ data: status }) 43 | res.status(200).json(httpResponse) 44 | }) 45 | ) 46 | 47 | route.use('/v1', v1Route) 48 | 49 | export { route as Route } 50 | -------------------------------------------------------------------------------- /src/app/database/migration/20250405080153-table-session.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { DataTypes, QueryInterface } from 'sequelize' 4 | 5 | /** @type {import('sequelize-cli').Migration} */ 6 | export async function up(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 7 | await queryInterface.createTable('session', { 8 | id: { 9 | allowNull: false, 10 | primaryKey: true, 11 | type: Sequelize.UUID, 12 | defaultValue: Sequelize.UUIDV4, 13 | }, 14 | created_at: { 15 | allowNull: false, 16 | type: Sequelize.DATE, 17 | }, 18 | updated_at: { 19 | allowNull: false, 20 | type: Sequelize.DATE, 21 | }, 22 | user_id: { 23 | allowNull: false, 24 | type: Sequelize.UUID, 25 | defaultValue: Sequelize.UUIDV4, 26 | references: { 27 | model: 'user', 28 | key: 'id', 29 | }, 30 | }, 31 | token: { 32 | allowNull: false, 33 | type: Sequelize.TEXT, 34 | }, 35 | ip_address: { 36 | type: Sequelize.STRING, 37 | }, 38 | device: { 39 | type: Sequelize.STRING, 40 | }, 41 | platform: { 42 | type: Sequelize.STRING, 43 | }, 44 | user_agent: { 45 | type: Sequelize.STRING, 46 | }, 47 | latitude: { 48 | type: Sequelize.STRING, 49 | }, 50 | longitude: { 51 | type: Sequelize.STRING, 52 | }, 53 | }) 54 | } 55 | 56 | export async function down(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 57 | await queryInterface.dropTable('session') 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/query-builder/query-helper.ts: -------------------------------------------------------------------------------- 1 | export class QueryHelper { 2 | private _valueQuery: Record = {} 3 | private readonly _data: any[] 4 | 5 | /** 6 | * Creates an instance of QueryHelper. 7 | * @param data - The data array to be used for lookup operations. 8 | */ 9 | constructor(data: any[]) { 10 | this._data = data 11 | } 12 | 13 | /** 14 | * Retrieves the value of an item from the data array by its id. 15 | * @param id - The id of the item to find. 16 | * @returns The value of the found item or undefined if not found. 17 | */ 18 | public getDataValueById(id: string): any { 19 | return this._data.find((item) => item.id === id)?.value 20 | } 21 | 22 | /** 23 | * Sets a query value for a specific id. 24 | * @param id - The id to associate with the value. 25 | * @param value - The value to store. 26 | */ 27 | public setQuery(id: string, value: any): void { 28 | this._valueQuery[id] = value 29 | } 30 | 31 | /** 32 | * Gets all query values. 33 | * @returns The complete query object. 34 | */ 35 | public getQuery(): Record { 36 | return this._valueQuery 37 | } 38 | 39 | /** 40 | * Gets a query value by its id. 41 | * @param id - The id of the query to retrieve. 42 | * @returns The value associated with the id. 43 | */ 44 | public getQueryById(id: string): any { 45 | return this._valueQuery[id] 46 | } 47 | 48 | /** 49 | * Deletes a query value by its id. 50 | * @param id - The id of the query to delete. 51 | * @returns True if the deletion was successful. 52 | */ 53 | public deleteQuery(id: string): boolean { 54 | return delete this._valueQuery[id] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/http/handle.ts: -------------------------------------------------------------------------------- 1 | import { blue, cyan, green } from 'colorette' 2 | import type http from 'http' 3 | import { env } from '~/config/env' 4 | import { logger } from '~/config/logger' 5 | 6 | export function httpHandle( 7 | server: http.Server, 8 | port: number 9 | ): { 10 | onError: (error: { syscall: string; code: string }) => void 11 | onListening: () => void 12 | } { 13 | /** 14 | * Handle HTTP Error 15 | * @param port 16 | * @param error 17 | */ 18 | const onError = (error: { syscall: string; code: string }): void => { 19 | if (error.syscall !== 'listen') { 20 | throw new Error() 21 | } 22 | 23 | const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}` 24 | 25 | // handle specific listen errors with friendly messages 26 | switch (error.code) { 27 | case 'EACCES': 28 | console.error(`${bind} requires elevated privileges`) 29 | process.exit(1) 30 | break 31 | case 'EADDRINUSE': 32 | console.error(`${bind} is already in use`) 33 | process.exit(1) 34 | break 35 | default: 36 | throw new Error() 37 | } 38 | } 39 | 40 | /** 41 | * On Listenting HTTP 42 | * @param server 43 | */ 44 | const onListening = (): void => { 45 | const addr = server.address() 46 | const bind = typeof addr === 'string' ? `${addr}` : `${addr?.port}` 47 | 48 | const host = cyan(`http://localhost:${bind}`) 49 | const nodeEnv = blue(env.NODE_ENV) 50 | 51 | const msgType = green(`${env.APP_NAME}`) 52 | const message = `server listening on ${host} ⚡️ & env: ${nodeEnv} 🚀` 53 | 54 | logger.info(`${msgType} - ${message}`) 55 | } 56 | 57 | return { onError, onListening } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/database/seed/20250405080441-user.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { green } from 'colorette' 4 | import _ from 'lodash' 5 | import { DataTypes, QueryInterface } from 'sequelize' 6 | import { v4 as uuidv4 } from 'uuid' 7 | import { env } from '~/config/env' 8 | import Hashing from '~/config/hashing' 9 | import { logger } from '~/config/logger' 10 | import { ConstRole } from '~/lib/constant/seed/role' 11 | 12 | const hashing = new Hashing() 13 | 14 | const defaultPassword = env.APP_DEFAULT_PASS 15 | logger.info(`Seed - your default password: ${green(defaultPassword)}`) 16 | 17 | const data = [ 18 | { 19 | fullname: 'Super Admin', 20 | email: 'super.admin@example.com', 21 | role_id: ConstRole.ID_SUPER_ADMIN, 22 | }, 23 | { 24 | fullname: 'Admin', 25 | email: 'admin@example.com', 26 | role_id: ConstRole.ID_ADMIN, 27 | }, 28 | { 29 | fullname: 'User', 30 | email: 'user@example.com', 31 | role_id: ConstRole.ID_USER, 32 | }, 33 | ] 34 | 35 | /** @type {import('sequelize-cli').Migration} */ 36 | export async function up(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 37 | const password = await hashing.hash(defaultPassword) 38 | 39 | const formData: any[] = [] 40 | 41 | if (!_.isEmpty(data)) { 42 | for (let i = 0; i < data.length; i += 1) { 43 | const item = data[i] 44 | 45 | formData.push({ 46 | ...item, 47 | id: uuidv4(), 48 | is_active: true, 49 | password, 50 | created_at: new Date(), 51 | updated_at: new Date(), 52 | }) 53 | } 54 | } 55 | 56 | await queryInterface.bulkInsert('user', formData) 57 | } 58 | 59 | export async function down(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 60 | await queryInterface.bulkDelete('user', {}) 61 | } 62 | -------------------------------------------------------------------------------- /script/secret.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | BASE_ENV_FILE=".env.example" 6 | ENV_FILE=".env" 7 | 8 | OS_TYPE=$(grep -w "ID" /etc/os-release 2>/dev/null | cut -d "=" -f 2 | tr -d '"' || echo "") 9 | OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release 2>/dev/null | cut -d "=" -f 2 | tr -d '"' || echo "") 10 | 11 | if [[ "$OSTYPE" == "darwin"* ]]; then 12 | OS_TYPE="macos" 13 | fi 14 | 15 | echo " - Detecting OS type: $OS_TYPE" 16 | echo " - Detecting OS version: $OS_VERSION" 17 | 18 | # Generate secure values 19 | SEQUELIZE_PASSWORD=$(openssl rand -hex 16) 20 | APP_DEFAULT_PASS=$(openssl rand -hex 8) 21 | APP_SECRET=$(openssl rand -base64 32) 22 | JWT_SECRET=$(openssl rand -base64 32) 23 | 24 | echo " - Generating secure values..." 25 | 26 | # Set sed command based on OS 27 | if [ "$OS_TYPE" = "macos" ]; then 28 | SED_CMD="sed -i ''" 29 | else 30 | SED_CMD="sed -i" 31 | fi 32 | 33 | if [ -f "$BASE_ENV_FILE" ]; then 34 | # Check if .env already exists 35 | if [ -f "$ENV_FILE" ]; then 36 | echo " - Warning: $ENV_FILE already exists. Please remove it manually." 37 | exit 1 38 | fi 39 | 40 | cp "$BASE_ENV_FILE" "$ENV_FILE" 41 | echo " - Copy $BASE_ENV_FILE to $ENV_FILE" 42 | 43 | # Update values in .env file 44 | $SED_CMD "s|^SEQUELIZE_PASSWORD=.*|SEQUELIZE_PASSWORD='$SEQUELIZE_PASSWORD'|" "$ENV_FILE" 45 | $SED_CMD "s|^APP_DEFAULT_PASS=.*|APP_DEFAULT_PASS='$APP_DEFAULT_PASS'|" "$ENV_FILE" 46 | $SED_CMD "s|^APP_SECRET=.*|APP_SECRET='$APP_SECRET'|" "$ENV_FILE" 47 | $SED_CMD "s|^JWT_SECRET=.*|JWT_SECRET='$JWT_SECRET'|" "$ENV_FILE" 48 | 49 | echo " - Secrets have been generated and saved to $ENV_FILE file" 50 | else 51 | echo " - Warning: $BASE_ENV_FILE does not exist. Cannot create environment file." 52 | exit 1 53 | fi 54 | 55 | echo -e "\nYour setup is ready to use!\n" 56 | -------------------------------------------------------------------------------- /src/lib/query-builder/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectionOptions, 3 | Includeable, 4 | IncludeOptions, 5 | ModelStatic, 6 | ModelType, 7 | Order, 8 | WhereOptions, 9 | } from 'sequelize' 10 | import SqlizeQuery from './sqlize-query' 11 | 12 | export type SequelizeOnBeforeBuildQuery = { 13 | paginationQuery: SqlizeQuery 14 | filteredQuery: SqlizeQuery 15 | sortedQuery: SqlizeQuery 16 | } 17 | 18 | export type SequelizeQueryOptions = { 19 | onBeforeBuild: (query: SequelizeOnBeforeBuildQuery) => void 20 | } 21 | 22 | export type SequelizeConnectionOptions = ConnectionOptions & { 23 | dialect?: string 24 | } 25 | 26 | export type SequelizeGetFilteredQuery = { 27 | model?: ModelStatic 28 | prefixName?: string 29 | options?: SequelizeConnectionOptions 30 | } 31 | 32 | export type SequelizeIncludeFilteredQuery = { 33 | filteredValue: any 34 | model: ModelType | undefined 35 | prefixName: string | undefined 36 | options?: IncludeOptions 37 | } 38 | 39 | export type SequelizeFilterIncludeHandledOnly = { 40 | include: any 41 | filteredInclude?: any 42 | } 43 | 44 | export type UseQuerySequelize = { 45 | model: ModelStatic 46 | reqQuery: RequestQuery 47 | includeRule?: Includeable | Includeable[] 48 | limit?: number 49 | options?: SequelizeQueryOptions 50 | } 51 | 52 | export type DtoSequelizeQuery = { 53 | include: Includeable | Includeable[] 54 | includeCount: Includeable | Includeable[] 55 | where: WhereOptions 56 | order: Order 57 | offset: number 58 | limit: number 59 | } 60 | 61 | export type QueryFilters = { 62 | id: string 63 | value: string 64 | } 65 | 66 | export type QuerySorts = { 67 | sort: string 68 | order: 'ASC' | 'DESC' 69 | } 70 | 71 | type RequestQuery = { 72 | filtered?: QueryFilters[] 73 | sorted?: QuerySorts[] 74 | page?: string | number 75 | pageSize?: string | number 76 | [key: string]: any 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/upload/multer.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import { Request } from 'express' 3 | import multer from 'multer' 4 | import slugify from 'slugify' 5 | import { logger } from '~/config/logger' 6 | import { default_allowed_ext } from '../constant/upload/allowed-extension' 7 | import { Mimetype } from '../constant/upload/allowed-mimetypes' 8 | import { MulterConfig } from './types' 9 | 10 | const mimetype = new Mimetype() 11 | 12 | const default_field_size = 10 * 1024 * 1024 // 10mb 13 | const default_file_size = 1 * 1024 * 1024 // 1mb 14 | const default_destination = `${process.cwd()}/public/uploads/` 15 | 16 | const msgType = `${green('multer')}` 17 | 18 | export function useMulter(values: MulterConfig): multer.Multer { 19 | const destination = values.dest ?? default_destination 20 | const allowedMimetype = values.allowed_mimetype ?? mimetype.default 21 | const allowedExt = values.allowed_ext ?? default_allowed_ext 22 | 23 | const storage = multer.diskStorage({ 24 | destination, 25 | filename: (_req: Request, file: Express.Multer.File, cb) => { 26 | const slugFilename = slugify(file.originalname, { 27 | replacement: '_', 28 | lower: true, 29 | }) 30 | cb(null, `${Date.now()}-${slugFilename}`) 31 | }, 32 | }) 33 | 34 | return multer({ 35 | storage, 36 | fileFilter: (_req, file, cb) => { 37 | const newMimetype = file.mimetype.toLowerCase() 38 | 39 | if (!allowedMimetype.includes(newMimetype)) { 40 | const extensions = allowedExt.join(', ') 41 | const errMessage = `Only ${extensions} extensions are allowed. Please check your file type.` 42 | logger.error(`${msgType} - ${errMessage}`) 43 | cb(new Error(errMessage)) 44 | return 45 | } 46 | 47 | cb(null, true) 48 | }, 49 | // @ts-expect-error 50 | limits: values.limit ?? { 51 | fieldSize: default_field_size, 52 | fileSize: default_file_size, 53 | }, 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | import type WithState from '~/lib/module/with-state' 2 | 3 | declare global { 4 | namespace Express { 5 | namespace Multer { 6 | /** Object containing file metadata and access information. */ 7 | interface File { 8 | /** Name of the form field associated with this file. */ 9 | fieldname: string 10 | /** Name of the file on the uploader's computer. */ 11 | originalname: string 12 | /** 13 | * Value of the `Content-Transfer-Encoding` header for this file. 14 | * @deprecated since July 2015 15 | * @see RFC 7578, Section 4.7 16 | */ 17 | encoding: string 18 | /** Value of the `Content-Type` header for this file. */ 19 | mimetype: string 20 | /** Size of the file in bytes. */ 21 | size: number 22 | /** 23 | * A readable stream of this file. Only available to the `_handleFile` 24 | * callback for custom `StorageEngine`s. 25 | */ 26 | stream: Readable 27 | /** `DiskStorage` only: Directory to which this file has been uploaded. */ 28 | destination: string 29 | /** `DiskStorage` only: Name of this file within `destination`. */ 30 | filename: string 31 | /** `DiskStorage` only: Full path to the uploaded file. */ 32 | path: string 33 | /** `MemoryStorage` only: A Buffer containing the entire file. */ 34 | buffer: Buffer 35 | } 36 | } 37 | 38 | interface Request extends WithState { 39 | state: object 40 | 41 | queryPolluted?: string[] 42 | 43 | /** `Multer.File` object populated by `single()` middleware. */ 44 | file?: Multer.File | undefined 45 | /** 46 | * Array or dictionary of `Multer.File` object populated by `array()`, 47 | * `fields()`, and `any()` middleware. 48 | */ 49 | files?: Record | Multer.File[] | undefined 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/http/response.ts: -------------------------------------------------------------------------------- 1 | type DataResponseEntity = { 2 | message?: string 3 | statusCode?: number 4 | } & TData 5 | 6 | type DtoHttpResponse = { 7 | statusCode: number 8 | message: string 9 | } & Omit, 'message' | 'statusCode'> 10 | 11 | export default class HttpResponse { 12 | /** 13 | * Base Response 14 | * @param dataResponse 15 | * @returns 16 | */ 17 | private static baseResponse( 18 | dataResponse: DataResponseEntity 19 | ): DtoHttpResponse { 20 | const { message = 'data has been received', statusCode = 200, ...rest } = dataResponse 21 | 22 | return { statusCode, message, ...rest } 23 | } 24 | 25 | /** 26 | * Response Get or Success 27 | * @param dataResponse 28 | * @returns 29 | */ 30 | public static get(dataResponse: DataResponseEntity): DtoHttpResponse { 31 | const message = 'data has been received' 32 | 33 | return this.baseResponse({ message, ...dataResponse }) 34 | } 35 | 36 | /** 37 | * Response Created 38 | * @param dataResponse 39 | * @returns 40 | */ 41 | public static created(dataResponse: DataResponseEntity): DtoHttpResponse { 42 | const message = 'data has been created' 43 | 44 | return this.baseResponse({ statusCode: 201, message, ...dataResponse }) 45 | } 46 | 47 | /** 48 | * Response Updated 49 | * @param dataResponse 50 | * @returns 51 | */ 52 | public static updated(dataResponse: DataResponseEntity): DtoHttpResponse { 53 | const message = 'data has been updated' 54 | 55 | return this.baseResponse({ message, ...dataResponse }) 56 | } 57 | 58 | /** 59 | * Response Deleted 60 | * @param dataResponse 61 | * @returns 62 | */ 63 | public static deleted(dataResponse: DataResponseEntity): DtoHttpResponse { 64 | const message = 'data has been deleted' 65 | 66 | return this.baseResponse({ message, ...dataResponse }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/smtp/nodemailer.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import nodemailer from 'nodemailer' 3 | import SMTPTransport from 'nodemailer/lib/smtp-transport' 4 | import { logger } from '~/config/logger' 5 | import { NodemailerParams } from './types' 6 | 7 | export default class Nodemailer { 8 | private _transporter: SMTPTransport | SMTPTransport.Options 9 | private _default: SMTPTransport.Options | undefined 10 | 11 | constructor({ transporter, defaults }: NodemailerParams) { 12 | this._transporter = transporter 13 | this._default = defaults 14 | } 15 | 16 | /** 17 | * Creates a nodemailer client 18 | * @returns nodemailer client 19 | */ 20 | private _client() { 21 | return nodemailer.createTransport(this._transporter, this._default) 22 | } 23 | 24 | /** 25 | * Initializes the nodemailer client 26 | * @returns nodemailer client 27 | */ 28 | async initialize() { 29 | const transporter = this._client() 30 | const msgType = `${green('nodemailer')}` 31 | 32 | try { 33 | const isValid = await transporter.verify() 34 | if (!isValid) { 35 | logger.error(`${msgType} failed to initialize Nodemailer`) 36 | process.exit(1) 37 | } 38 | 39 | logger.info(`${msgType} initialized successfully`) 40 | return transporter 41 | } catch (error: any) { 42 | logger.error(`${msgType} failed to initialize: ${error?.message ?? error}`) 43 | process.exit(1) 44 | } 45 | } 46 | 47 | /** 48 | * Sends an email 49 | * @param options - options for sending an email 50 | * @returns email info 51 | */ 52 | async send(options: nodemailer.SendMailOptions) { 53 | const transporter = this._client() 54 | const msgType = `${green('nodemailer')}` 55 | 56 | try { 57 | const info = await transporter.sendMail(options) 58 | logger.info(`${msgType} mail sent successfully: ${info.messageId}`) 59 | return info 60 | } catch (error: any) { 61 | logger.error(`${msgType} failed to send mail: ${error?.message ?? error}`) 62 | throw error 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { validate } from '~/lib/validate' 3 | 4 | export const env = { 5 | NODE_ENV: process.env.NODE_ENV || 'development', 6 | APP_PORT: validate.number(process.env.APP_PORT) || 8000, 7 | APP_NAME: process.env.APP_NAME || 'Backend', 8 | APP_URL: process.env.APP_URL || 'http://localhost:8000', 9 | APP_DEFAULT_PASS: process.env.APP_DEFAULT_PASS || 'yourpassword', 10 | 11 | SEQUELIZE_CONNECTION: process.env.SEQUELIZE_CONNECTION || 'postgres', 12 | SEQUELIZE_HOST: process.env.SEQUELIZE_HOST || '127.0.0.1', 13 | SEQUELIZE_PORT: validate.number(process.env.SEQUELIZE_PORT) || 5432, 14 | SEQUELIZE_DATABASE: process.env.SEQUELIZE_DATABASE || 'postgres', 15 | SEQUELIZE_USERNAME: process.env.SEQUELIZE_USERNAME || 'postgres', 16 | SEQUELIZE_PASSWORD: process.env.SEQUELIZE_PASSWORD || 'postgres', 17 | SEQUELIZE_SYNC: validate.boolean(process.env.SEQUELIZE_SYNC) || false, 18 | SEQUELIZE_LOGGING: validate.boolean(process.env.SEQUELIZE_LOGGING) || true, 19 | SEQUELIZE_TIMEZONE: process.env.SEQUELIZE_TIMEZONE || 'Asia/Jakarta', 20 | 21 | STORAGE_PROVIDER: process.env.STORAGE_PROVIDER || 'gcs', 22 | STORAGE_HOST: process.env.STORAGE_HOST || '', 23 | STORAGE_PORT: validate.number(process.env.STORAGE_PORT), 24 | STORAGE_ACCESS_KEY: process.env.STORAGE_ACCESS_KEY || '', 25 | STORAGE_SECRET_KEY: process.env.STORAGE_SECRET_KEY || '', 26 | STORAGE_BUCKET_NAME: process.env.STORAGE_BUCKET_NAME || '', 27 | STORAGE_REGION: process.env.STORAGE_REGION || '', 28 | STORAGE_SIGN_EXPIRED: process.env.STORAGE_SIGN_EXPIRED || '7d', 29 | STORAGE_FILEPATH: process.env.STORAGE_FILEPATH || '', 30 | 31 | JWT_SECRET: process.env.JWT_SECRET || 'your_secret_key', 32 | JWT_EXPIRES: process.env.JWT_EXPIRES || '7d', 33 | 34 | MAIL_DRIVER: process.env.MAIL_DRIVER || 'smtp', 35 | MAIL_HOST: process.env.MAIL_HOST || 'smtp.mailtrap.io', 36 | MAIL_PORT: validate.number(process.env.MAIL_PORT) || 587, 37 | MAIL_FROM: process.env.MAIL_FROM, 38 | MAIL_USERNAME: process.env.MAIL_USERNAME, 39 | MAIL_PASSWORD: process.env.MAIL_PASSWORD, 40 | MAIL_ENCRYPTION: process.env.MAIL_ENCRYPTION, 41 | } 42 | -------------------------------------------------------------------------------- /src/app/middleware/with-permission.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import { NextFunction, Request, Response } from 'express' 3 | import { logger } from '~/config/logger' 4 | import { asyncHandler } from '~/lib/async-handler' 5 | import User from '../database/entity/user' 6 | import { UserLoginState } from '../database/schema/user' 7 | 8 | export function permissionAccess(roleIds: string[]) { 9 | return asyncHandler(async (req: Request, res: Response, next: NextFunction) => { 10 | const repo = { 11 | user: User, 12 | } 13 | 14 | const { uid: user_id } = req.getState('userLoginState') as UserLoginState 15 | const getUser = await repo.user.findOne({ where: { id: user_id } }) 16 | 17 | const errType = `permitted access error:` 18 | const errMessage = 'you are not allowed' 19 | 20 | if (getUser && !roleIds.includes(getUser.role_id)) { 21 | const msgType = green('permission') 22 | logger.error(`${msgType} - ${errType} ${errMessage}`) 23 | 24 | const result = { 25 | statusCode: 403, 26 | error: 'Forbidden', 27 | message: `${errType} ${errMessage}`, 28 | } 29 | 30 | return res.status(403).json(result) 31 | } 32 | 33 | next() 34 | }) 35 | } 36 | 37 | export function notPermittedAccess(roleIds: string[]) { 38 | return asyncHandler(async (req: Request, res: Response, next: NextFunction) => { 39 | const repo = { 40 | user: User, 41 | } 42 | 43 | const { uid: user_id } = req.getState('userLoginState') as UserLoginState 44 | const getUser = await repo.user.findOne({ where: { id: user_id } }) 45 | 46 | const errType = `not permitted access error:` 47 | const errMessage = 'you are not allowed' 48 | 49 | if (getUser && roleIds.includes(getUser.role_id)) { 50 | const msgType = green('permission') 51 | logger.error(`${msgType} - ${errType} ${errMessage}`) 52 | 53 | const result = { 54 | statusCode: 403, 55 | error: 'Forbidden', 56 | message: `${errType} ${errMessage}`, 57 | } 58 | 59 | return res.status(403).json(result) 60 | } 61 | 62 | next() 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/smtp/template/auth.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import fs from 'fs' 3 | import Handlebars from 'handlebars' 4 | import path from 'path' 5 | import { env } from '~/config/env' 6 | import { logger } from '~/config/logger' 7 | import { smtp } from '~/config/smtp' 8 | import { readHTMLFile } from '~/lib/fs/read-file' 9 | import ErrorResponse from '~/lib/http/errors' 10 | import { currentDir } from '~/lib/string' 11 | 12 | /** 13 | * Returns the path to the email template 14 | * @param htmlPath - the path to the email template 15 | * @returns the path to the email template 16 | */ 17 | function _emailTemplatePath(htmlPath: string) { 18 | const _path = path.resolve(`${currentDir}/public/email-template/${htmlPath}`) 19 | 20 | const msgType = green('email template') 21 | logger.info(`${msgType} - ${_path} exists`) 22 | 23 | return _path 24 | } 25 | 26 | /** 27 | * Sends an email 28 | * @param _path - the path to the email template 29 | * @param data - the data to be sent 30 | * @returns the email template 31 | */ 32 | async function _sendMail(_path: string, data: any) { 33 | if (!fs.existsSync(_path)) { 34 | throw new ErrorResponse.BadRequest('invalid template path ') 35 | } 36 | 37 | const html = await readHTMLFile(_path) 38 | const template = Handlebars.compile(html) 39 | const htmlToSend = template(data) 40 | 41 | await smtp.send({ 42 | to: data.email, 43 | subject: data.subject, 44 | text: data.text, 45 | html: htmlToSend, 46 | }) 47 | } 48 | 49 | type SendEmailRegistrationParams = { 50 | fullname: string 51 | email: string 52 | url_token: string 53 | } 54 | 55 | /** 56 | * Sends an email to the user 57 | * @param values - the data to be sent 58 | */ 59 | export async function SendEmailRegistration(values: SendEmailRegistrationParams) { 60 | const _path = _emailTemplatePath('register.html') 61 | 62 | const { fullname, url_token } = values 63 | const subject = `${fullname}, Thank you for registering on the ${env.APP_NAME} App` 64 | const text = `Please click the link below to verify your email: ${env.APP_URL}/verify/${url_token}` 65 | 66 | const data = { ...values, subject, text, APP_NAME: env.APP_NAME } 67 | await _sendMail(_path, data) 68 | } 69 | -------------------------------------------------------------------------------- /src/app/database/migration/20250405080045-table-user.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { DataTypes, QueryInterface } from 'sequelize' 4 | 5 | /** @type {import('sequelize-cli').Migration} */ 6 | export async function up(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 7 | await queryInterface.createTable('user', { 8 | id: { 9 | allowNull: false, 10 | primaryKey: true, 11 | type: Sequelize.UUID, 12 | defaultValue: Sequelize.UUIDV4, 13 | }, 14 | created_at: { 15 | allowNull: false, 16 | type: Sequelize.DATE, 17 | }, 18 | updated_at: { 19 | allowNull: false, 20 | type: Sequelize.DATE, 21 | }, 22 | deleted_at: { 23 | type: Sequelize.DATE, 24 | }, 25 | fullname: { 26 | allowNull: false, 27 | type: Sequelize.STRING, 28 | }, 29 | email: { 30 | allowNull: false, 31 | type: Sequelize.STRING, 32 | }, 33 | password: { 34 | allowNull: false, 35 | type: Sequelize.STRING, 36 | }, 37 | phone: { 38 | type: Sequelize.STRING('20'), 39 | }, 40 | token_verify: { 41 | type: Sequelize.TEXT, 42 | }, 43 | address: { 44 | type: Sequelize.TEXT, 45 | }, 46 | is_active: { 47 | allowNull: false, 48 | defaultValue: false, 49 | type: Sequelize.BOOLEAN, 50 | }, 51 | is_blocked: { 52 | allowNull: false, 53 | defaultValue: false, 54 | type: Sequelize.BOOLEAN, 55 | }, 56 | role_id: { 57 | allowNull: false, 58 | type: Sequelize.UUID, 59 | defaultValue: Sequelize.UUIDV4, 60 | references: { 61 | model: 'role', 62 | key: 'id', 63 | }, 64 | }, 65 | upload_id: { 66 | allowNull: true, 67 | type: Sequelize.UUID, 68 | defaultValue: Sequelize.UUIDV4, 69 | references: { 70 | model: 'upload', 71 | key: 'id', 72 | }, 73 | }, 74 | }) 75 | 76 | await queryInterface.addConstraint('user', { 77 | type: 'unique', 78 | fields: ['email'], 79 | name: 'UNIQUE_USERS_EMAIL', 80 | }) 81 | } 82 | 83 | export async function down(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { 84 | await queryInterface.dropTable('user') 85 | } 86 | -------------------------------------------------------------------------------- /src/app/service/session.ts: -------------------------------------------------------------------------------- 1 | import { subDays } from 'date-fns' 2 | import { Model, ModelStatic, Op } from 'sequelize' 3 | import ErrorResponse from '~/lib/http/errors' 4 | import { validate } from '~/lib/validate' 5 | import Session from '../database/entity/session' 6 | import { SessionSchema, sessionSchema } from '../database/schema/session' 7 | import BaseService from './base' 8 | 9 | // Define a type that ensures Session is recognized as a Sequelize Model 10 | type SessionModel = Session & Model 11 | 12 | type FindByUserTokenParams = { 13 | user_id: string 14 | token: string 15 | } 16 | 17 | export default class SessionService extends BaseService { 18 | constructor() { 19 | super({ 20 | repository: Session as unknown as ModelStatic, 21 | schema: sessionSchema, 22 | model: 'session', 23 | }) 24 | } 25 | 26 | /** 27 | * Create or update session 28 | */ 29 | async createOrUpdate(formData: SessionSchema) { 30 | const values = sessionSchema.parse(formData) 31 | const session = await this.repository.findOne({ where: { user_id: values.user_id } }) 32 | 33 | if (session) { 34 | await session.update({ ...session, ...values }) 35 | } 36 | 37 | await this.repository.create(values) 38 | } 39 | 40 | /** 41 | * Find by user token 42 | */ 43 | async findByUserToken({ user_id, token }: FindByUserTokenParams): Promise { 44 | const newUserId = validate.uuid(user_id) 45 | const record = await this.repository.findOne({ where: { user_id: newUserId, token } }) 46 | 47 | if (!record) { 48 | throw new ErrorResponse.NotFound('session not found') 49 | } 50 | 51 | return record 52 | } 53 | 54 | /** 55 | * Find by token 56 | */ 57 | async findByToken(token: string): Promise { 58 | const record = await this.repository.findOne({ where: { token } }) 59 | 60 | if (!record) { 61 | throw new ErrorResponse.NotFound('session not found') 62 | } 63 | 64 | return record 65 | } 66 | 67 | /** 68 | * Delete by user token 69 | */ 70 | async deleteByUserToken({ user_id, token }: FindByUserTokenParams): Promise { 71 | const newUserId = validate.uuid(user_id) 72 | await this.repository.destroy({ where: { user_id: newUserId, token } }) 73 | } 74 | 75 | /** 76 | * Delete expired session 77 | */ 78 | async deleteExpiredSession() { 79 | const subSevenDays = subDays(new Date(), 7) 80 | await this.repository.destroy({ where: { created_at: { [Op.lte]: subSevenDays } } }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/app/database/schema/user.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import BaseSchema from '../entity/base' 3 | 4 | // Schema 5 | const passwordSchema = z.object({ 6 | new_password: z.string().min(8, 'new password at least 8 characters'), 7 | confirm_new_password: z.string().min(8, 'confirm new password at least 8 characters'), 8 | }) 9 | 10 | export const createPasswordSchema = passwordSchema.refine( 11 | (data) => data.new_password === data.confirm_new_password, 12 | { 13 | message: "passwords don't match", 14 | path: ['confirm_new_password'], // path of error 15 | } 16 | ) 17 | 18 | export const changePasswordSchema = passwordSchema 19 | .extend({ 20 | current_password: z.string().min(8, 'current password at least 8 characters'), 21 | }) 22 | .refine((data) => data.new_password === data.confirm_new_password, { 23 | message: "passwords don't match", 24 | path: ['confirm_new_password'], // path of error 25 | }) 26 | 27 | export const userSchema = passwordSchema 28 | .extend({ 29 | fullname: z.string('fullname is required').min(2, "fullname can't be empty"), 30 | email: z.email({ message: 'invalid email address' }).min(2, "email can't be empty"), 31 | phone: z.string().nullable(), 32 | token_verify: z.string().nullable(), 33 | upload_id: z.uuid('upload_id invalid uuid format').nullable(), 34 | is_active: z.boolean('is_active is required'), 35 | is_blocked: z.boolean('is_blocked is required'), 36 | role_id: z.uuid('role id invalid uuid format').min(2, `role id can't be empty`), 37 | }) 38 | .refine((data) => data.new_password === data.confirm_new_password, { 39 | message: "passwords don't match", 40 | path: ['confirm_new_password'], // path of error 41 | }) 42 | 43 | export const loginSchema = z.object({ 44 | email: z.email('invalid email address').min(2, "email can't be empty"), 45 | password: z.string().min(2, "password can't be empty"), 46 | latitude: z.string().nullable(), 47 | longitude: z.string().nullable(), 48 | ip_address: z.string().nullable().optional(), 49 | device: z.string().nullable().optional(), 50 | platform: z.string().nullable().optional(), 51 | user_agent: z.string().nullable().optional(), 52 | }) 53 | 54 | // Type 55 | export type UserSchema = Omit, 'new_password' | 'confirm_new_password'> & 56 | Partial & { 57 | deleted_at: Date | null 58 | } 59 | 60 | export type CreatePasswordSchema = z.infer 61 | export type ChangePasswordSchema = z.infer 62 | export type LoginSchema = z.infer 63 | export type UserLoginState = { 64 | uid: string 65 | } 66 | -------------------------------------------------------------------------------- /src/config/app.ts: -------------------------------------------------------------------------------- 1 | import compression from 'compression' 2 | import cookieParser from 'cookie-parser' 3 | import cors from 'cors' 4 | import express, { Application, Request, Response } from 'express' 5 | import userAgent from 'express-useragent' 6 | import helmet from 'helmet' 7 | import hpp from 'hpp' 8 | import path from 'path' 9 | import requestIp from 'request-ip' 10 | import expressErrorHandle from '~/app/middleware/error-handle' 11 | import expressErrorSequelize from '~/app/middleware/error-sequelize' 12 | import expressErrorValidation from '~/app/middleware/error-validation' 13 | import expressRateLimit from '~/app/middleware/rate-limit' 14 | import expressUserAgent from '~/app/middleware/user-agent' 15 | import expressWithState from '~/app/middleware/with-state' 16 | import { Route } from '~/app/routes/route' 17 | import { allowedCors } from '~/lib/constant/allowed-cors' 18 | import ErrorResponse from '~/lib/http/errors' 19 | import { currentDir } from '~/lib/string' 20 | import { httpLogger } from './logger' 21 | 22 | export class App { 23 | private _app: Application 24 | 25 | constructor() { 26 | this._app = express() 27 | this._plugins() 28 | this._routes() 29 | } 30 | 31 | private _plugins() { 32 | this._app.use(httpLogger) 33 | this._app.use(express.json({ limit: '20mb', type: 'application/json' })) 34 | this._app.use(express.urlencoded({ extended: true })) 35 | this._app.use(express.static(path.resolve(`${currentDir}/public`))) 36 | this._app.use(compression()) 37 | this._app.use(cookieParser()) 38 | this._app.use(helmet()) 39 | this._app.use(cors({ origin: allowedCors })) 40 | this._app.use(hpp()) 41 | this._app.use(requestIp.mw()) 42 | this._app.use(userAgent.express()) 43 | 44 | this._app.use(expressRateLimit()) 45 | this._app.use(expressWithState()) 46 | this._app.use(expressUserAgent()) 47 | } 48 | 49 | private _routes() { 50 | this._app.use(Route) 51 | 52 | // Catch error 404 endpoint not found 53 | this._app.use('*', (req: Request, _res: Response) => { 54 | const method = req.method 55 | const url = req.originalUrl 56 | const host = req.hostname 57 | 58 | const endpoint = `${host}${url}` 59 | 60 | throw new ErrorResponse.NotFound( 61 | `Sorry, the ${endpoint} HTTP method ${method} resource you are looking for was not found.` 62 | ) 63 | }) 64 | } 65 | 66 | public get create() { 67 | // error validation handle 68 | this._app.use(expressErrorValidation) 69 | 70 | // error sequelize handle 71 | this._app.use(expressErrorSequelize) 72 | 73 | // error global handle 74 | this._app.use(expressErrorHandle) 75 | 76 | return this._app 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/token/jwt.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import jwt from 'jsonwebtoken' 3 | import _ from 'lodash' 4 | import { logger } from '~/config/logger' 5 | import { ms } from '../date' 6 | 7 | type JwtTokenParams = { 8 | secret: string 9 | expires: string 10 | } 11 | 12 | export default class JwtToken { 13 | private _secret: string 14 | private _expires: number 15 | private _expiresIn: number 16 | 17 | constructor({ secret, expires }: JwtTokenParams) { 18 | this._secret = secret 19 | this._expires = ms(expires) 20 | this._expiresIn = Number(this._expires) / 1000 21 | } 22 | 23 | /** 24 | * Generate a JWT token 25 | */ 26 | generate(payload: any) { 27 | const token = jwt.sign(payload, this._secret, { expiresIn: this._expiresIn }) 28 | return { token, expiresIn: this._expiresIn } 29 | } 30 | 31 | /** 32 | * Extract token from request 33 | */ 34 | extract(req: Request): string | null { 35 | const queryToken = _.get(req, 'query.token', undefined) 36 | const cookieToken = _.get(req, 'cookies.token', undefined) 37 | const headerToken = _.get(req, 'headers.authorization', undefined) 38 | 39 | if (queryToken) { 40 | logger.info('Token extracted from query') 41 | return String(queryToken) 42 | } 43 | 44 | if (cookieToken) { 45 | logger.info('Token extracted from cookie') 46 | return String(cookieToken) 47 | } 48 | 49 | if (headerToken) { 50 | const splitAuthorize = headerToken.split(' ') 51 | const allowedAuthorize = ['Bearer', 'JWT', 'Token'] 52 | 53 | if (splitAuthorize.length !== 2 || !allowedAuthorize.includes(splitAuthorize[0])) { 54 | return null 55 | } 56 | 57 | logger.info('Token extracted from header') 58 | return String(splitAuthorize[1]) 59 | } 60 | 61 | logger.info('Token not found') 62 | return null 63 | } 64 | 65 | /** 66 | * Verify a JWT token 67 | */ 68 | verify(token: string) { 69 | try { 70 | if (!token) return { data: null, message: 'unauthorized, invalid token' } 71 | 72 | const decoded = jwt.verify(token, this._secret) 73 | return { data: decoded, message: 'success' } 74 | } catch (error: any) { 75 | if (error instanceof jwt.TokenExpiredError) { 76 | return { data: null, message: `unauthorized, token expired ${error.message || error}` } 77 | } 78 | 79 | if (error instanceof jwt.JsonWebTokenError) { 80 | return { data: null, message: `unauthorized, invalid token ${error.message || error}` } 81 | } 82 | 83 | if (error instanceof jwt.NotBeforeError) { 84 | return { data: null, message: `unauthorized, token not before ${error.message || error}` } 85 | } 86 | 87 | return { data: null, message: `unauthorized, invalid token ${error.message || error}` } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/handler/auth.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express' 2 | import { env } from '~/config/env' 3 | import { asyncHandler } from '~/lib/async-handler' 4 | import ErrorResponse from '~/lib/http/errors' 5 | import HttpResponse from '~/lib/http/response' 6 | import JwtToken from '~/lib/token/jwt' 7 | import { UserLoginState } from '../database/schema/user' 8 | import authorization from '../middleware/authorization' 9 | import { UserAgentState } from '../middleware/user-agent' 10 | import AuthService from '../service/auth' 11 | 12 | const route = express.Router() 13 | const service = new AuthService() 14 | 15 | const jwt = new JwtToken({ secret: env.JWT_SECRET, expires: env.JWT_EXPIRES }) 16 | 17 | route.post( 18 | '/sign-up', 19 | asyncHandler(async (req: Request, res: Response) => { 20 | const values = req.getBody() 21 | await service.register(values) 22 | const httpResponse = HttpResponse.created({ message: 'User registered successfully' }) 23 | res.status(201).json(httpResponse) 24 | }) 25 | ) 26 | 27 | route.post( 28 | '/sign-in', 29 | asyncHandler(async (req: Request, res: Response) => { 30 | const values = req.getBody() 31 | const userAgentState = req.getState('userAgent') as UserAgentState 32 | const clientIp = req.getState('clientIp') as string 33 | 34 | const data = await service.login({ 35 | ...values, 36 | ip_address: clientIp, 37 | device: userAgentState.device, 38 | platform: userAgentState.platform, 39 | user_agent: userAgentState.source, 40 | }) 41 | 42 | const httpResponse = HttpResponse.get({ 43 | data, 44 | message: 'Login successfully', 45 | }) 46 | 47 | res 48 | .status(200) 49 | .cookie('token', data.access_token, { 50 | maxAge: Number(data.expires_in) * 1000, 51 | httpOnly: true, 52 | path: '/v1', 53 | secure: process.env.NODE_ENV === 'production', 54 | }) 55 | .json(httpResponse) 56 | }) 57 | ) 58 | 59 | route.get( 60 | '/verify-session', 61 | authorization(), 62 | asyncHandler(async (req: Request, res: Response) => { 63 | const token = jwt.extract(req) 64 | const { uid: user_id } = req.getState('userLoginState') as UserLoginState 65 | 66 | const data = await service.verifySession({ token: String(token), user_id }) 67 | const httpResponse = HttpResponse.get({ 68 | data, 69 | message: 'Session verified successfully', 70 | }) 71 | res.status(200).json(httpResponse) 72 | }) 73 | ) 74 | 75 | route.post( 76 | '/sign-out', 77 | authorization(), 78 | asyncHandler(async (req: Request, res: Response) => { 79 | const formData = req.getBody() 80 | 81 | const token = jwt.extract(req) 82 | const { uid: user_id } = req.getState('userLoginState') as UserLoginState 83 | 84 | if (formData.user_id !== user_id) { 85 | throw new ErrorResponse.Forbidden('you are not allowed') 86 | } 87 | 88 | const { message } = await service.logout({ token: String(token), user_id }) 89 | 90 | const httpResponse = HttpResponse.get({ message }) 91 | res.status(200).json(httpResponse) 92 | }) 93 | ) 94 | 95 | export { route as AuthHandler } 96 | -------------------------------------------------------------------------------- /src/lib/swagger/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import _ from 'lodash' 3 | import path from 'path' 4 | import swaggerJSDoc from 'swagger-jsdoc' 5 | import { env } from '~/config/env' 6 | import { currentDir } from '../string' 7 | 8 | const _pathRouteDocs = path.resolve(`${currentDir}/public/swagger/routes`) 9 | const _pathSchemaDocs = path.resolve(`${currentDir}/public/swagger/schema`) 10 | 11 | function _getDocsSwaggers(_path: string | Buffer): Record { 12 | return fs.readdirSync(_path).reduce((acc, file) => { 13 | const data = require(`${_path}/${file}`) 14 | acc = { ...acc, ...data } 15 | 16 | return acc 17 | }, {}) 18 | } 19 | 20 | const routesDocs = _getDocsSwaggers(_pathRouteDocs) 21 | const schemaDocs = _getDocsSwaggers(_pathSchemaDocs) 22 | 23 | const baseURLServer = [ 24 | { 25 | url: env.APP_URL, 26 | description: `${_.capitalize(env.NODE_ENV)} Server`, 27 | }, 28 | ] 29 | 30 | const swaggerOptURL = [ 31 | { 32 | url: `${env.APP_URL}/v1/swagger.json`, 33 | name: `${_.capitalize(env.NODE_ENV)} Server`, 34 | }, 35 | ] 36 | 37 | export const swaggerOptions = { 38 | definition: { 39 | info: { 40 | title: `Api ${env.APP_NAME} Docs`, 41 | description: `This is Api Documentation ${env.APP_NAME}`, 42 | license: { 43 | name: 'MIT', 44 | }, 45 | version: '1.0.0', 46 | }, 47 | openapi: '3.0.1', 48 | servers: baseURLServer, 49 | components: { 50 | securitySchemes: { 51 | auth_token: { 52 | type: 'apiKey', 53 | in: 'header', 54 | name: 'Authorization', 55 | description: 56 | 'JWT Authorization header using the JWT scheme. Example: “Authorization: JWT {token}”', 57 | }, 58 | }, 59 | schemas: schemaDocs, 60 | parameters: { 61 | page: { 62 | in: 'query', 63 | name: 'page', 64 | schema: { type: 'string' }, 65 | required: false, 66 | }, 67 | pageSize: { 68 | in: 'query', 69 | name: 'pageSize', 70 | schema: { type: 'string' }, 71 | required: false, 72 | }, 73 | filtered: { 74 | in: 'query', 75 | name: 'filtered', 76 | schema: { type: 'string' }, 77 | required: false, 78 | description: 'example: [{"id": "email", "value": "anyValue"}]', 79 | }, 80 | sorted: { 81 | in: 'query', 82 | name: 'sorted', 83 | schema: { type: 'string' }, 84 | required: false, 85 | description: 'example: [{"sort": "created_at", "order": "DESC"}]', 86 | }, 87 | lang: { 88 | in: 'query', 89 | name: 'lang', 90 | schema: { type: 'string', enum: ['en', 'id'] }, 91 | required: false, 92 | }, 93 | }, 94 | }, 95 | paths: routesDocs, 96 | }, 97 | apis: [], 98 | } 99 | 100 | export const swaggerSpec = swaggerJSDoc(swaggerOptions) 101 | export const optionsSwaggerUI = { 102 | explorer: true, 103 | swaggerOptions: { urls: swaggerOptURL }, 104 | } 105 | -------------------------------------------------------------------------------- /src/app/handler/role.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express' 2 | import { asyncHandler } from '~/lib/async-handler' 3 | import { ConstRole } from '~/lib/constant/seed/role' 4 | import HttpResponse from '~/lib/http/response' 5 | import authorization from '../middleware/authorization' 6 | import { permissionAccess } from '../middleware/with-permission' 7 | import RoleService from '../service/role' 8 | 9 | const route = express.Router() 10 | const service = new RoleService() 11 | 12 | route.get( 13 | '/', 14 | authorization(), 15 | permissionAccess(ConstRole.ROLE_ADMIN), 16 | asyncHandler(async (req: Request, res: Response) => { 17 | const { page, pageSize, filtered, sorted } = req.getQuery() 18 | const records = await service.find({ page, pageSize, filtered, sorted }) 19 | const httpResponse = HttpResponse.get({ data: records }) 20 | res.status(200).json(httpResponse) 21 | }) 22 | ) 23 | 24 | route.get( 25 | '/:id', 26 | authorization(), 27 | permissionAccess(ConstRole.ROLE_ADMIN), 28 | asyncHandler(async (req: Request, res: Response) => { 29 | const { id } = req.getParams() 30 | const record = await service.findById(id) 31 | const httpResponse = HttpResponse.get({ data: record }) 32 | res.status(200).json(httpResponse) 33 | }) 34 | ) 35 | 36 | route.post( 37 | '/', 38 | authorization(), 39 | permissionAccess(ConstRole.ROLE_ADMIN), 40 | asyncHandler(async (req: Request, res: Response) => { 41 | const values = req.getBody() 42 | const record = await service.create(values) 43 | const httpResponse = HttpResponse.created({ data: record }) 44 | res.status(201).json(httpResponse) 45 | }) 46 | ) 47 | 48 | route.put( 49 | '/:id', 50 | authorization(), 51 | permissionAccess(ConstRole.ROLE_ADMIN), 52 | asyncHandler(async (req: Request, res: Response) => { 53 | const { id } = req.getParams() 54 | const values = req.getBody() 55 | const record = await service.update(id, values) 56 | const httpResponse = HttpResponse.updated({ data: record }) 57 | res.status(200).json(httpResponse) 58 | }) 59 | ) 60 | 61 | route.put( 62 | '/restore/:id', 63 | authorization(), 64 | permissionAccess(ConstRole.ROLE_ADMIN), 65 | asyncHandler(async (req: Request, res: Response) => { 66 | const { id } = req.getParams() 67 | await service.restore(id) 68 | const httpResponse = HttpResponse.updated({}) 69 | res.status(200).json(httpResponse) 70 | }) 71 | ) 72 | 73 | route.delete( 74 | '/soft-delete/:id', 75 | authorization(), 76 | permissionAccess(ConstRole.ROLE_ADMIN), 77 | asyncHandler(async (req: Request, res: Response) => { 78 | const { id } = req.getParams() 79 | await service.softDelete(id) 80 | const httpResponse = HttpResponse.deleted({}) 81 | res.status(200).json(httpResponse) 82 | }) 83 | ) 84 | 85 | route.delete( 86 | '/force-delete/:id', 87 | authorization(), 88 | permissionAccess(ConstRole.ROLE_ADMIN), 89 | asyncHandler(async (req: Request, res: Response) => { 90 | const { id } = req.getParams() 91 | await service.forceDelete(id) 92 | const httpResponse = HttpResponse.deleted({}) 93 | res.status(200).json(httpResponse) 94 | }) 95 | ) 96 | 97 | export { route as RoleHandler } 98 | -------------------------------------------------------------------------------- /src/app/handler/session.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express' 2 | import { asyncHandler } from '~/lib/async-handler' 3 | import { ConstRole } from '~/lib/constant/seed/role' 4 | import HttpResponse from '~/lib/http/response' 5 | import authorization from '../middleware/authorization' 6 | import { permissionAccess } from '../middleware/with-permission' 7 | import SessionService from '../service/session' 8 | 9 | const route = express.Router() 10 | const service = new SessionService() 11 | 12 | route.get( 13 | '/', 14 | authorization(), 15 | permissionAccess(ConstRole.ROLE_ADMIN), 16 | asyncHandler(async (req: Request, res: Response) => { 17 | const { page, pageSize, filtered, sorted } = req.getQuery() 18 | const records = await service.find({ page, pageSize, filtered, sorted }) 19 | const httpResponse = HttpResponse.get({ data: records }) 20 | res.status(200).json(httpResponse) 21 | }) 22 | ) 23 | 24 | route.get( 25 | '/:id', 26 | authorization(), 27 | permissionAccess(ConstRole.ROLE_ADMIN), 28 | asyncHandler(async (req: Request, res: Response) => { 29 | const { id } = req.getParams() 30 | const record = await service.findById(id) 31 | const httpResponse = HttpResponse.get({ data: record }) 32 | res.status(200).json(httpResponse) 33 | }) 34 | ) 35 | 36 | route.post( 37 | '/', 38 | authorization(), 39 | permissionAccess(ConstRole.ROLE_ADMIN), 40 | asyncHandler(async (req: Request, res: Response) => { 41 | const values = req.getBody() 42 | const record = await service.create(values) 43 | const httpResponse = HttpResponse.created({ data: record }) 44 | res.status(201).json(httpResponse) 45 | }) 46 | ) 47 | 48 | route.put( 49 | '/:id', 50 | authorization(), 51 | permissionAccess(ConstRole.ROLE_ADMIN), 52 | asyncHandler(async (req: Request, res: Response) => { 53 | const { id } = req.getParams() 54 | const values = req.getBody() 55 | const record = await service.update(id, values) 56 | const httpResponse = HttpResponse.updated({ data: record }) 57 | res.status(200).json(httpResponse) 58 | }) 59 | ) 60 | 61 | route.put( 62 | '/restore/:id', 63 | authorization(), 64 | permissionAccess(ConstRole.ROLE_ADMIN), 65 | asyncHandler(async (req: Request, res: Response) => { 66 | const { id } = req.getParams() 67 | await service.restore(id) 68 | const httpResponse = HttpResponse.updated({}) 69 | res.status(200).json(httpResponse) 70 | }) 71 | ) 72 | 73 | route.delete( 74 | '/soft-delete/:id', 75 | authorization(), 76 | permissionAccess(ConstRole.ROLE_ADMIN), 77 | asyncHandler(async (req: Request, res: Response) => { 78 | const { id } = req.getParams() 79 | await service.softDelete(id) 80 | const httpResponse = HttpResponse.deleted({}) 81 | res.status(200).json(httpResponse) 82 | }) 83 | ) 84 | 85 | route.delete( 86 | '/force-delete/:id', 87 | authorization(), 88 | permissionAccess(ConstRole.ROLE_ADMIN), 89 | asyncHandler(async (req: Request, res: Response) => { 90 | const { id } = req.getParams() 91 | await service.forceDelete(id) 92 | const httpResponse = HttpResponse.deleted({}) 93 | res.status(200).json(httpResponse) 94 | }) 95 | ) 96 | 97 | export { route as SessionHandler } 98 | -------------------------------------------------------------------------------- /src/app/handler/user.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express' 2 | import { asyncHandler } from '~/lib/async-handler' 3 | import { ConstRole } from '~/lib/constant/seed/role' 4 | import HttpResponse from '~/lib/http/response' 5 | import authorization from '../middleware/authorization' 6 | import { permissionAccess } from '../middleware/with-permission' 7 | import UserService from '../service/user' 8 | 9 | const route = express.Router() 10 | const service = new UserService() 11 | 12 | route.get( 13 | '/', 14 | authorization(), 15 | permissionAccess(ConstRole.ROLE_ADMIN), 16 | asyncHandler(async (req: Request, res: Response) => { 17 | const { page, pageSize, filtered, sorted } = req.getQuery() 18 | const records = await service.findWithRelations({ page, pageSize, filtered, sorted }) 19 | const httpResponse = HttpResponse.get({ data: records }) 20 | res.status(200).json(httpResponse) 21 | }) 22 | ) 23 | 24 | route.get( 25 | '/:id', 26 | authorization(), 27 | permissionAccess(ConstRole.ROLE_ADMIN), 28 | asyncHandler(async (req: Request, res: Response) => { 29 | const { id } = req.getParams() 30 | const record = await service.findByIdWithRelation(id) 31 | const httpResponse = HttpResponse.get({ data: record }) 32 | res.status(200).json(httpResponse) 33 | }) 34 | ) 35 | 36 | route.post( 37 | '/', 38 | authorization(), 39 | permissionAccess(ConstRole.ROLE_ADMIN), 40 | asyncHandler(async (req: Request, res: Response) => { 41 | const values = req.getBody() 42 | const record = await service.create(values) 43 | const httpResponse = HttpResponse.created({ data: record }) 44 | res.status(201).json(httpResponse) 45 | }) 46 | ) 47 | 48 | route.put( 49 | '/:id', 50 | authorization(), 51 | permissionAccess(ConstRole.ROLE_ADMIN), 52 | asyncHandler(async (req: Request, res: Response) => { 53 | const { id } = req.getParams() 54 | const values = req.getBody() 55 | const record = await service.update(id, values) 56 | const httpResponse = HttpResponse.updated({ data: record }) 57 | res.status(200).json(httpResponse) 58 | }) 59 | ) 60 | 61 | route.put( 62 | '/restore/:id', 63 | authorization(), 64 | permissionAccess(ConstRole.ROLE_ADMIN), 65 | asyncHandler(async (req: Request, res: Response) => { 66 | const { id } = req.getParams() 67 | await service.restore(id) 68 | const httpResponse = HttpResponse.updated({}) 69 | res.status(200).json(httpResponse) 70 | }) 71 | ) 72 | 73 | route.delete( 74 | '/soft-delete/:id', 75 | authorization(), 76 | permissionAccess(ConstRole.ROLE_ADMIN), 77 | asyncHandler(async (req: Request, res: Response) => { 78 | const { id } = req.getParams() 79 | await service.softDelete(id) 80 | const httpResponse = HttpResponse.deleted({}) 81 | res.status(200).json(httpResponse) 82 | }) 83 | ) 84 | 85 | route.delete( 86 | '/force-delete/:id', 87 | authorization(), 88 | permissionAccess(ConstRole.ROLE_ADMIN), 89 | asyncHandler(async (req: Request, res: Response) => { 90 | const { id } = req.getParams() 91 | await service.forceDelete(id) 92 | const httpResponse = HttpResponse.deleted({}) 93 | res.status(200).json(httpResponse) 94 | }) 95 | ) 96 | 97 | export { route as UserHandler } 98 | -------------------------------------------------------------------------------- /src/app/database/entity/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BeforeCreate, 3 | BeforeUpdate, 4 | BelongsTo, 5 | Column, 6 | DataType, 7 | DefaultScope, 8 | DeletedAt, 9 | ForeignKey, 10 | HasMany, 11 | IsUUID, 12 | Scopes, 13 | Table, 14 | Unique, 15 | } from 'sequelize-typescript' 16 | import Hashing from '~/config/hashing' 17 | import { createPasswordSchema } from '../schema/user' 18 | import BaseSchema from './base' 19 | import Role from './role' 20 | import Session from './session' 21 | import Upload from './upload' 22 | 23 | const hashing = new Hashing() 24 | 25 | @DefaultScope(() => ({ 26 | attributes: { 27 | exclude: ['password', 'token_verify'], 28 | }, 29 | })) 30 | @Scopes(() => ({ 31 | withPassword: {}, 32 | })) 33 | @Table({ tableName: 'user', paranoid: true }) 34 | export default class User extends BaseSchema { 35 | @DeletedAt 36 | @Column 37 | deleted_at?: Date 38 | 39 | @Column({ allowNull: false }) 40 | fullname: string 41 | 42 | @Unique 43 | @Column({ allowNull: false }) 44 | email: string 45 | 46 | @Column({ allowNull: false }) 47 | password?: string 48 | 49 | @Column({ type: DataType.STRING('20') }) 50 | phone?: string 51 | 52 | @Column({ type: DataType.TEXT }) 53 | token_verify?: string 54 | 55 | @Column({ type: DataType.TEXT }) 56 | address?: string 57 | 58 | @Column({ 59 | type: DataType.BOOLEAN, 60 | allowNull: false, 61 | defaultValue: false, 62 | }) 63 | is_active?: boolean 64 | 65 | @Column({ 66 | type: DataType.BOOLEAN, 67 | allowNull: false, 68 | defaultValue: false, 69 | }) 70 | is_blocked?: boolean 71 | 72 | @IsUUID(4) 73 | @ForeignKey(() => Role) 74 | @Column({ 75 | type: DataType.UUID, 76 | defaultValue: DataType.UUIDV4, 77 | allowNull: false, 78 | }) 79 | role_id: string 80 | 81 | @BelongsTo(() => Role) 82 | role: Role 83 | 84 | @IsUUID(4) 85 | @ForeignKey(() => Upload) 86 | @Column({ 87 | type: DataType.UUID, 88 | defaultValue: DataType.UUIDV4, 89 | }) 90 | upload_id: string 91 | 92 | // many to one 93 | @BelongsTo(() => Upload) 94 | upload?: Upload 95 | 96 | // one to many 97 | @HasMany(() => Session) 98 | sessions: Session[] 99 | 100 | @Column({ type: DataType.VIRTUAL }) 101 | new_password: string 102 | 103 | @Column({ type: DataType.VIRTUAL }) 104 | confirm_new_password: string 105 | 106 | comparePassword: (current_password: string) => Promise 107 | 108 | @BeforeUpdate 109 | @BeforeCreate 110 | static async setUserPassword(instance: User): Promise { 111 | const { new_password, confirm_new_password } = instance 112 | 113 | if (new_password ?? confirm_new_password) { 114 | const formPassword = { new_password, confirm_new_password } 115 | const validPassword = createPasswordSchema.parse(formPassword) 116 | 117 | const hash = await hashing.hash(validPassword.new_password) 118 | instance.setDataValue('password', hash) 119 | } 120 | } 121 | } 122 | 123 | // compare password 124 | User.prototype.comparePassword = async function (current_password: string): Promise { 125 | const password = String(this.password) 126 | 127 | const compare = await hashing.verify(password, current_password) 128 | return compare 129 | } 130 | -------------------------------------------------------------------------------- /src/lib/module/with-state.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import _ from 'lodash' 3 | import getterObject from '../getter-object' 4 | import { pickMultiFieldMulter, pickSingleFieldMulter } from './with-multer' 5 | 6 | export default class WithState { 7 | private readonly req: Request 8 | 9 | constructor(req: Request) { 10 | this.req = req 11 | 12 | this.req.setState = this.setState.bind(this) 13 | this.req.setBody = this.setBody.bind(this) 14 | this.req.setFieldState = this.setFieldState.bind(this) 15 | this.req.getState = this.getState.bind(this) 16 | this.req.getCookies = this.getCookies.bind(this) 17 | this.req.getHeaders = this.getHeaders.bind(this) 18 | this.req.getQuery = this.getQuery.bind(this) 19 | this.req.getQueryPolluted = this.getQueryPolluted.bind(this) 20 | this.req.getParams = this.getParams.bind(this) 21 | this.req.getBody = this.getBody.bind(this) 22 | this.req.getSingleArrayFile = this.getSingleArrayFile.bind(this) 23 | this.req.pickSingleFieldMulter = this.pickSingleFieldMulter.bind(this) 24 | this.req.getMultiArrayFile = this.getMultiArrayFile.bind(this) 25 | this.req.pickMultiFieldMulter = this.pickMultiFieldMulter.bind(this) 26 | } 27 | 28 | setState(value: object): void { 29 | this.req.state = { 30 | ...(this.req.state || {}), 31 | ...value, 32 | } 33 | } 34 | 35 | setBody(value: object): void { 36 | this.req.body = { 37 | ...this.req.body, 38 | ...value, 39 | } 40 | } 41 | 42 | setFieldState(key: any, value: any): void { 43 | _.set(this.req.state, key, value) 44 | } 45 | 46 | getState(path: any, defaultValue?: any): any { 47 | return _.get(this.req.state, path, defaultValue) 48 | } 49 | 50 | getCookies(path?: any, defaultValue?: any): any { 51 | return getterObject(this.req.cookies, path, defaultValue) 52 | } 53 | 54 | getHeaders(path?: any, defaultValue?: any): any { 55 | return getterObject(this.req.headers, path, defaultValue) 56 | } 57 | 58 | getQuery(path?: any, defaultValue?: any): any { 59 | return getterObject(this.req.query, path, defaultValue) 60 | } 61 | 62 | getQueryPolluted(path?: any, defaultValue?: any): any { 63 | return getterObject(this.req.queryPolluted, path, defaultValue) 64 | } 65 | 66 | getParams(path?: any, defaultValue?: any): any { 67 | return getterObject(this.req.params, path, defaultValue) 68 | } 69 | 70 | getBody(path?: any, defaultValue?: any): any { 71 | return getterObject(this.req.body, path, defaultValue) 72 | } 73 | 74 | getSingleArrayFile(name: string): any { 75 | const data = getterObject( 76 | this.req, 77 | ['files', name, '0'].join('.') 78 | ) as unknown as Express.Multer.File 79 | if (data) { 80 | return data 81 | } 82 | } 83 | 84 | pickSingleFieldMulter(fields: string[]): Partial { 85 | return pickSingleFieldMulter(this.req, fields) 86 | } 87 | 88 | getMultiArrayFile(name: string): any { 89 | const data = _.get(this.req.files, name, []) as unknown as Express.Multer.File 90 | 91 | if (data) { 92 | return data 93 | } 94 | } 95 | 96 | pickMultiFieldMulter(fields: string[]): Partial { 97 | return pickMultiFieldMulter(this.req, fields) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/config/logger.ts: -------------------------------------------------------------------------------- 1 | import { blue, green } from 'colorette' 2 | import { randomUUID } from 'crypto' 3 | import path from 'path' 4 | import pino from 'pino' 5 | import { pinoHttp } from 'pino-http' 6 | import { currentDir } from '~/lib/string' 7 | 8 | const logDir = path.resolve(`${currentDir}/logs`) 9 | 10 | const fileTransport = pino.transport({ 11 | target: 'pino/file', 12 | options: { destination: `${logDir}/server.log` }, 13 | }) 14 | 15 | export const logger = pino( 16 | { 17 | level: 'info', 18 | formatters: { 19 | level: (label) => { 20 | return { level: label.toUpperCase() } 21 | }, 22 | }, 23 | transport: { 24 | target: 'pino-pretty', 25 | options: { 26 | colorize: true, 27 | ignore: 'pid,hostname', 28 | translateTime: 'SYS:dd-mm-yyyy HH:MM:ss', 29 | }, 30 | }, 31 | }, 32 | fileTransport 33 | ) 34 | 35 | export const httpLogger = pinoHttp({ 36 | logger, 37 | 38 | // Define a custom request id function 39 | genReqId: function (req, res) { 40 | const existingID = req.id ?? req.headers['x-request-id'] 41 | if (existingID) return existingID 42 | 43 | const id = randomUUID() 44 | res.setHeader('X-Request-Id', id) 45 | return id 46 | }, 47 | 48 | // Define a custom logger level 49 | customLogLevel: function (req, res, err) { 50 | if (res.statusCode >= 400 && res.statusCode < 500) { 51 | return 'warn' 52 | } else if (res.statusCode >= 500 || err) { 53 | return 'error' 54 | } else if (res.statusCode >= 300 && res.statusCode < 400) { 55 | return 'silent' 56 | } 57 | return 'info' 58 | }, 59 | 60 | // Define a custom serialize logger 61 | serializers: { 62 | req: (req) => ({ 63 | id: req.id, 64 | method: req.method, 65 | url: req.url, 66 | query: req.query, 67 | params: req.params, 68 | headers: req.headers, 69 | body: req.body || req.raw.body, 70 | }), 71 | res: (res) => ({ 72 | statusCode: res.statusCode, 73 | message: res.message, 74 | data: res.data, 75 | 'x-request-id': res.headers['x-request-id'], 76 | 'x-ratelimit-limit': res.headers['x-ratelimit-limit'], 77 | 'x-ratelimit-remaining': res.headers['x-ratelimit-remaining'], 78 | }), 79 | }, 80 | 81 | // Define a custom receive message 82 | customReceivedMessage: function (req, res) { 83 | // @ts-expect-error 84 | const endpoint = `${req?.hostname}${req?.originalUrl}` 85 | const method = green(`${req.method}`) 86 | const statusCode = blue(`${res.statusCode}`) 87 | 88 | return `incoming request: ${statusCode} - ${method} [${endpoint}]` 89 | }, 90 | 91 | // Define a custom success message 92 | customSuccessMessage: function (req, res) { 93 | // @ts-expect-error 94 | const endpoint = `${req?.hostname}${req?.originalUrl}` 95 | 96 | const statusCode = blue(`${res.statusCode}`) 97 | const method = green(`${req.method}`) 98 | 99 | if (res.statusCode === 404) { 100 | return `${statusCode} - ${method} [${endpoint}] resource not found` 101 | } 102 | 103 | return `${statusCode} - ${method} [${endpoint}] completed` 104 | }, 105 | 106 | // Define a custom error message 107 | customErrorMessage: function (req, res, err) { 108 | return `request errored with status code: ${res.statusCode}, error : ${err.message}` 109 | }, 110 | }) 111 | -------------------------------------------------------------------------------- /src/app/service/user.ts: -------------------------------------------------------------------------------- 1 | import { Includeable, Model, ModelStatic } from 'sequelize' 2 | import ErrorResponse from '~/lib/http/errors' 3 | import { useQuery } from '~/lib/query-builder' 4 | import { validate } from '~/lib/validate' 5 | import Role from '../database/entity/role' 6 | import Session from '../database/entity/session' 7 | import Upload from '../database/entity/upload' 8 | import User from '../database/entity/user' 9 | import { changePasswordSchema, ChangePasswordSchema, userSchema } from '../database/schema/user' 10 | import BaseService from './base' 11 | import { DtoFindAll, FindParams } from './types' 12 | 13 | // Define a type that ensures User is recognized as a Sequelize Model 14 | type UserModel = User & Model 15 | type RoleModel = Role & Model 16 | type UploadModel = Upload & Model 17 | type SessionModel = Session & Model 18 | 19 | const relations: Includeable[] = [ 20 | { model: Role as unknown as ModelStatic }, 21 | { model: Upload as unknown as ModelStatic }, 22 | { model: Session as unknown as ModelStatic }, 23 | ] 24 | 25 | export default class UserService extends BaseService { 26 | constructor() { 27 | super({ 28 | repository: User as unknown as ModelStatic, 29 | schema: userSchema, 30 | model: 'user', 31 | }) 32 | } 33 | 34 | /** 35 | * Find all with relations 36 | */ 37 | async findWithRelations({ 38 | page, 39 | pageSize, 40 | filtered = [], 41 | sorted = [], 42 | }: FindParams): Promise> { 43 | const query = useQuery({ 44 | model: this.repository, 45 | reqQuery: { page, pageSize, filtered, sorted }, 46 | includeRule: relations, 47 | }) 48 | 49 | const data = await this.repository.findAll({ 50 | ...query, 51 | order: query.order ? query.order : [['created_at', 'desc']], 52 | }) 53 | 54 | const total = await this.repository.count({ 55 | include: query.includeCount, 56 | where: query.where, 57 | }) 58 | 59 | return { data, total } 60 | } 61 | 62 | /** 63 | * Find by id with relation 64 | */ 65 | async findByIdWithRelation(id: string) { 66 | const newId = validate.uuid(id) 67 | const record = await this._findOne({ 68 | where: { id: newId }, 69 | include: relations, 70 | rejectOnEmpty: true, 71 | }) 72 | 73 | return record 74 | } 75 | 76 | /** 77 | * Check email 78 | */ 79 | async checkEmail(email: string) { 80 | const record = await this.repository.findOne({ where: { email } }) 81 | 82 | if (record) { 83 | throw new ErrorResponse.BadRequest('email already exists') 84 | } 85 | } 86 | 87 | /** 88 | * Change password 89 | */ 90 | async changePassword(id: string, formData: ChangePasswordSchema) { 91 | const newId = validate.uuid(id) 92 | const values = changePasswordSchema.parse(formData) 93 | 94 | const record = await this.repository.findOne({ 95 | attributes: ['id', 'email', 'password', 'is_active', 'role_id'], 96 | where: { id: newId }, 97 | }) 98 | 99 | if (!record) { 100 | throw new ErrorResponse.NotFound('user not found') 101 | } 102 | 103 | const isPasswordMatch = await record.comparePassword(values.current_password) 104 | if (!isPasswordMatch) { 105 | throw new ErrorResponse.BadRequest('current password is incorrect') 106 | } 107 | 108 | // update password 109 | await record.update({ password: values.new_password }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/app/service/upload.ts: -------------------------------------------------------------------------------- 1 | import { sub } from 'date-fns' 2 | import _ from 'lodash' 3 | import { Model, ModelStatic, Op } from 'sequelize' 4 | import { validate as uuidValidate } from 'uuid' 5 | import { storage } from '~/config/storage' 6 | import ErrorResponse from '~/lib/http/errors' 7 | import { FileParams } from '~/lib/storage/types' 8 | import Upload from '../database/entity/upload' 9 | import { UploadSchema, uploadSchema } from '../database/schema/upload' 10 | import BaseService from './base' 11 | 12 | // Define a type that ensures Upload is recognized as a Sequelize Model 13 | type UploadModel = Upload & Model 14 | 15 | type UploadFileParams = { 16 | file: FileParams 17 | directory: string 18 | upload_id?: string 19 | } 20 | 21 | export default class UploadService extends BaseService { 22 | constructor() { 23 | super({ 24 | repository: Upload as unknown as ModelStatic, 25 | schema: uploadSchema, 26 | model: 'upload', 27 | }) 28 | } 29 | 30 | /** 31 | * Find upload by keyfile 32 | */ 33 | async findByKeyfile(keyfile: string): Promise { 34 | const record = await this.repository.findOne({ where: { keyfile } }) 35 | 36 | if (!record) { 37 | throw new ErrorResponse.NotFound('upload not found') 38 | } 39 | 40 | return record 41 | } 42 | 43 | /** 44 | * Find upload with presigned url 45 | */ 46 | async findWithPresignedUrl(keyfile: string) { 47 | const record = await this.findByKeyfile(keyfile) 48 | const signedUrl = storage.presignedUrl(record.keyfile) 49 | 50 | const value = uploadSchema.parse({ ...record, signed_url: signedUrl }) 51 | const data = await this.repository.update(value, { where: { id: record.id } }) 52 | 53 | return data 54 | } 55 | 56 | /** 57 | * Create or update upload 58 | */ 59 | async createOrUpdate(formData: UploadSchema, upload_id?: string) { 60 | const values = uploadSchema.parse(formData) 61 | 62 | if (upload_id && uuidValidate(upload_id)) { 63 | const record = await this.repository.findOne({ where: { id: upload_id } }) 64 | 65 | if (record) { 66 | return record.update({ ...record, ...values }) 67 | } 68 | } 69 | 70 | return this.repository.create(values) 71 | } 72 | 73 | /** 74 | * Upload file to storage 75 | */ 76 | async uploadFile({ file, directory, upload_id }: UploadFileParams) { 77 | const { expiryDate } = storage.expiresObject() 78 | const keyfile = `${directory}/${file.filename}` 79 | 80 | const { data, signedUrl } = await storage.uploadFile({ file, directory }) 81 | 82 | const formValues = { 83 | ...file, 84 | keyfile, 85 | signed_url: signedUrl, 86 | expiry_date_url: expiryDate, 87 | } 88 | 89 | const result = await this.createOrUpdate(formValues, upload_id) 90 | return { storage: data, upload: result } 91 | } 92 | 93 | /** 94 | * Update signed url for old upload 95 | */ 96 | async updateSignedUrl() { 97 | const fiveDaysAgo = sub(new Date(), { days: 5 }) 98 | 99 | const records = await this.repository.findAll({ 100 | where: { updated_at: { [Op.lte]: fiveDaysAgo } }, 101 | limit: 10, 102 | order: [['updated_at', 'ASC']], 103 | }) 104 | 105 | const { expiryDate } = storage.expiresObject() 106 | 107 | if (!_.isEmpty(records)) { 108 | for (const record of records) { 109 | const signedUrl = storage.presignedUrl(record.keyfile) 110 | const formValues = { 111 | ...record, 112 | signed_url: signedUrl, 113 | expiry_date_url: expiryDate, 114 | } 115 | 116 | await record.update({ ...record, ...formValues }) 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/storage/minio.ts: -------------------------------------------------------------------------------- 1 | import { green } from 'colorette' 2 | import { addDays } from 'date-fns' 3 | import * as Minio from 'minio' 4 | import { logger } from '~/config/logger' 5 | import { ms } from '../date' 6 | import { MinIOStorageParams, UploadFileParams } from './types' 7 | 8 | export default class MinIOStorage { 9 | public client: Minio.Client 10 | private _access_key: string 11 | private _secret_key: string 12 | private _bucket: string 13 | private _expires: string 14 | private _region: string 15 | private _host: string 16 | private _port: number 17 | private _ssl: boolean 18 | 19 | constructor(params: MinIOStorageParams) { 20 | this._access_key = params.access_key 21 | this._secret_key = params.secret_key 22 | this._bucket = params.bucket 23 | this._expires = params.expires 24 | this._region = params.region 25 | this._host = params.host 26 | this._port = params.port 27 | this._ssl = params.ssl 28 | 29 | this.client = new Minio.Client({ 30 | endPoint: this._host || '127.0.0.1', 31 | port: this._port || 9000, 32 | useSSL: this._ssl || false, 33 | accessKey: this._access_key, 34 | secretKey: this._secret_key, 35 | }) 36 | } 37 | 38 | /** 39 | * Generate keyfile 40 | */ 41 | private _generateKeyfile(values: string[]) { 42 | return values.join('/') 43 | } 44 | 45 | /** 46 | * Get expires object 47 | */ 48 | public expiresObject() { 49 | const getExpired = this._expires.replace(/[^0-9]/g, '') 50 | 51 | const expiresIn = ms(this._expires) 52 | const expiryDate = addDays(new Date(), Number(getExpired)) 53 | 54 | return { expiresIn, expiryDate } 55 | } 56 | 57 | /** 58 | * Initialize storage 59 | */ 60 | async initialize() { 61 | const msgType = `${green('storage - minio')}` 62 | const bucketName = this._bucket 63 | 64 | const exists = await this.client.bucketExists(bucketName) 65 | 66 | if (!exists) { 67 | await this._createBucket() 68 | } else { 69 | const message = `${msgType} - ${bucketName} bucket found` 70 | logger.info(message) 71 | } 72 | } 73 | 74 | /** 75 | * Create bucket 76 | */ 77 | private async _createBucket() { 78 | const msgType = `${green('storage - minio')}` 79 | const bucketName = this._bucket 80 | 81 | try { 82 | const data = await this.client.makeBucket(bucketName, this._region) 83 | 84 | const message = `${msgType} - ${bucketName} bucket created` 85 | logger.info(message) 86 | console.log(data) 87 | } catch (error: any) { 88 | const message = `${msgType} error: ${error.message ?? error}` 89 | logger.error(message) 90 | process.exit(1) 91 | } 92 | } 93 | 94 | /** 95 | * Upload file 96 | */ 97 | async uploadFile({ directory, file }: UploadFileParams) { 98 | const keyfile = this._generateKeyfile([directory, file.filename]) 99 | 100 | const options = { 101 | ContentType: file.mimetype, // <-- this is what you need! 102 | ContentDisposition: `inline; filename=${file.filename}`, // <-- and this ! 103 | ACL: 'public-read', // <-- this makes it public so people can see it 104 | } 105 | 106 | const data = await this.client.fPutObject(this._bucket, keyfile, file.path, options) 107 | const signedUrl = await this.presignedUrl(keyfile) 108 | 109 | return { data, signedUrl } 110 | } 111 | 112 | /** 113 | * Generate presigned URL 114 | */ 115 | async presignedUrl(keyfile: string) { 116 | const msgType = `${green('storage - minio')}` 117 | const bucketName = this._bucket 118 | 119 | const signedUrl = await this.client.presignedGetObject(bucketName, keyfile) 120 | 121 | const message = `${msgType} - ${keyfile} presigned URL generated` 122 | logger.info(message) 123 | 124 | return signedUrl 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-api-sequelize", 3 | "version": "6.0.0-3", 4 | "description": "A simple Express API with TypeScript, Sequelize, and PostgreSQL", 5 | "main": "src/main.ts", 6 | "private": true, 7 | "type": "commonjs", 8 | "scripts": { 9 | "secret": "chmod +x ./script/secret.sh && bash ./script/secret.sh", 10 | "start": "nodemon --exec node ./dist/main.js", 11 | "clean": "rimraf dist", 12 | "prebuild": "npm run clean", 13 | "build": "tsc && tsc-alias", 14 | "dev": "concurrently \"npm run build:watch\" \"npm run watch-dev\"", 15 | "build:watch": "tsc && (concurrently \"tsc -w\" \"tsc-alias -w\")", 16 | "watch-dev": "nodemon --watch \"dist/**/*\" -e js ./dist/main.js", 17 | "start:staging": "NODE_ENV=staging pm2-runtime ./dist/main.js", 18 | "start:production": "NODE_ENV=production pm2-runtime ./dist/main.js", 19 | "test": "echo \"Error: no test specified\" && exit 1", 20 | "db:migrate": "npx sequelize-cli db:migrate", 21 | "db:migrate-fresh": "npx sequelize-cli db:migrate:undo:all && npm run db:migrate", 22 | "db:seed": "npx sequelize-cli db:seed:all", 23 | "db:drop": "npx sequelize-cli db:drop", 24 | "db:create": "npx sequelize-cli db:create", 25 | "db:reset": "npm-run-all db:drop db:create db:migrate db:seed", 26 | "prerelease": "npm-run-all prebuild", 27 | "postrelease": "git push --follow-tags origin main", 28 | "release": "release-it", 29 | "release:pre": "release-it --preRelease=beta", 30 | "release:patch": "release-it patch", 31 | "release:minor": "release-it minor", 32 | "release:major": "release-it major" 33 | }, 34 | "keywords": [ 35 | "express", 36 | "api", 37 | "typescript" 38 | ], 39 | "author": "masb0ymas ", 40 | "license": "MIT", 41 | "engines": { 42 | "node": ">=20.x" 43 | }, 44 | "dependencies": { 45 | "@aws-sdk/client-s3": "^3.896.0", 46 | "@aws-sdk/s3-request-presigner": "^3.896.0", 47 | "@google-cloud/storage": "^7.17.1", 48 | "argon2": "^0.41.1", 49 | "colorette": "^2.0.20", 50 | "compression": "^1.8.1", 51 | "concurrently": "^9.2.1", 52 | "cookie-parser": "^1.4.7", 53 | "cors": "^2.8.5", 54 | "cronstrue": "^3.3.0", 55 | "date-fns": "^4.1.0", 56 | "dotenv": "^17.2.2", 57 | "express": "^4.21.2", 58 | "express-async-handler": "^1.2.0", 59 | "express-rate-limit": "^8.1.0", 60 | "express-useragent": "^1.0.15", 61 | "handlebars": "^4.7.8", 62 | "helmet": "^8.1.0", 63 | "hpp": "^0.2.3", 64 | "jsonwebtoken": "^9.0.2", 65 | "lodash": "^4.17.21", 66 | "minio": "^8.0.6", 67 | "multer": "^2.0.2", 68 | "node-cron": "^3.0.3", 69 | "nodemailer": "^7.0.6", 70 | "npm-run-all": "^4.1.5", 71 | "pg": "^8.16.3", 72 | "pg-hstore": "^2.3.4", 73 | "pino": "^9.11.0", 74 | "pino-http": "^10.5.0", 75 | "pino-pretty": "^13.1.1", 76 | "pm2-runtime": "^5.4.1", 77 | "reflect-metadata": "^0.2.2", 78 | "request-ip": "^3.3.0", 79 | "sequelize": "^6.37.7", 80 | "sequelize-cli": "^6.6.3", 81 | "sequelize-typescript": "^2.1.6", 82 | "slugify": "^1.6.6", 83 | "swagger-jsdoc": "^6.2.8", 84 | "swagger-ui-express": "^5.0.1", 85 | "uuid": "^11.1.0", 86 | "zod": "^4.1.11" 87 | }, 88 | "devDependencies": { 89 | "@eslint/js": "^9.36.0", 90 | "@types/compression": "^1.8.1", 91 | "@types/cookie-parser": "^1.4.9", 92 | "@types/cors": "^2.8.19", 93 | "@types/express": "^5.0.3", 94 | "@types/express-useragent": "^1.0.5", 95 | "@types/hpp": "^0.2.6", 96 | "@types/jsonwebtoken": "^9.0.10", 97 | "@types/lodash": "^4.17.20", 98 | "@types/multer": "^2.0.0", 99 | "@types/node": "^24.5.2", 100 | "@types/node-cron": "^3.0.11", 101 | "@types/nodemailer": "^7.0.1", 102 | "@types/request-ip": "^0.0.41", 103 | "@types/swagger-jsdoc": "^6.0.4", 104 | "@types/swagger-ui-express": "^4.1.8", 105 | "eslint": "^9.36.0", 106 | "eslint-config-prettier": "^10.1.8", 107 | "nodemon": "^3.1.10", 108 | "prettier": "^3.6.2", 109 | "release-it": "^18.1.2", 110 | "rimraf": "^6.0.1", 111 | "tsc-alias": "^1.8.16", 112 | "typescript": "^5.9.2", 113 | "typescript-eslint": "^8.44.1" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /public/swagger/routes/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "/v1/auth/sign-up": { 3 | "post": { 4 | "tags": ["Auth"], 5 | "summary": "Create New User", 6 | "security": [{ "auth_token": [] }], 7 | "requestBody": { 8 | "required": true, 9 | "content": { 10 | "application/x-www-form-urlencoded": { 11 | "schema": { 12 | "type": "object", 13 | "properties": { 14 | "fullname": { 15 | "type": "string" 16 | }, 17 | "email": { 18 | "type": "string" 19 | }, 20 | "new_password": { 21 | "type": "string", 22 | "format": "password" 23 | }, 24 | "confirm_new_password": { 25 | "type": "string", 26 | "format": "password" 27 | }, 28 | "phone": { 29 | "type": "string" 30 | } 31 | }, 32 | "required": ["fullname", "email", "new_password", "confirm_new_password"] 33 | } 34 | } 35 | } 36 | }, 37 | "responses": { 38 | "201": { "description": "Create new records" }, 39 | "400": { "description": "Something went wrong" }, 40 | "422": { "description": "Unprocessable Entity" }, 41 | "500": { "description": "Internal Server Error" } 42 | } 43 | } 44 | }, 45 | "/v1/auth/sign-in": { 46 | "post": { 47 | "tags": ["Auth"], 48 | "summary": "Login User", 49 | "security": [{ "auth_token": [] }], 50 | "requestBody": { 51 | "required": true, 52 | "content": { 53 | "application/x-www-form-urlencoded": { 54 | "schema": { 55 | "type": "object", 56 | "properties": { 57 | "email": { 58 | "type": "string" 59 | }, 60 | "password": { 61 | "type": "string", 62 | "format": "password" 63 | }, 64 | "latitude": { 65 | "type": "string" 66 | }, 67 | "longitude": { 68 | "type": "string" 69 | }, 70 | "ip_address": { 71 | "type": "string" 72 | }, 73 | "user_agent": { 74 | "type": "string" 75 | } 76 | }, 77 | "required": ["email", "password"] 78 | } 79 | } 80 | } 81 | }, 82 | "responses": { 83 | "200": { "description": "Login successfully" }, 84 | "400": { "description": "Something went wrong" }, 85 | "422": { "description": "Unprocessable Entity" }, 86 | "500": { "description": "Internal Server Error" } 87 | } 88 | } 89 | }, 90 | "/v1/auth/verify-session": { 91 | "get": { 92 | "tags": ["Auth"], 93 | "summary": "Verify User Session", 94 | "security": [{ "auth_token": [] }], 95 | "responses": { 96 | "200": { "description": "Verify session successfully" }, 97 | "400": { "description": "Something went wrong" }, 98 | "422": { "description": "Unprocessable Entity" }, 99 | "500": { "description": "Internal Server Error" } 100 | } 101 | } 102 | }, 103 | "/v1/auth/sign-out": { 104 | "post": { 105 | "tags": ["Auth"], 106 | "summary": "Logout User", 107 | "security": [{ "auth_token": [] }], 108 | "requestBody": { 109 | "required": true, 110 | "content": { 111 | "application/x-www-form-urlencoded": { 112 | "schema": { 113 | "type": "object", 114 | "properties": { 115 | "user_id": { 116 | "type": "string" 117 | } 118 | }, 119 | "required": ["user_id"] 120 | } 121 | } 122 | } 123 | }, 124 | "responses": { 125 | "200": { "description": "Logout successfully" }, 126 | "400": { "description": "Something went wrong" }, 127 | "422": { "description": "Unprocessable Entity" }, 128 | "500": { "description": "Internal Server Error" } 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/storage/s3.ts: -------------------------------------------------------------------------------- 1 | import * as S3Client from '@aws-sdk/client-s3' 2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner' 3 | import { green } from 'colorette' 4 | import { addDays } from 'date-fns' 5 | import fs from 'fs' 6 | import { logger } from '~/config/logger' 7 | import { ms } from '~/lib/date' 8 | import { S3StorageParams, UploadFileParams } from './types' 9 | 10 | export default class S3Storage { 11 | public client: S3Client.S3 12 | private _access_key: string 13 | private _secret_key: string 14 | private _bucket: string 15 | private _expires: string 16 | private _region: string 17 | 18 | constructor(params: S3StorageParams) { 19 | this._access_key = params.access_key 20 | this._secret_key = params.secret_key 21 | this._bucket = params.bucket 22 | this._expires = params.expires 23 | this._region = params.region 24 | 25 | this.client = new S3Client.S3({ 26 | region: this._region, 27 | credentials: { 28 | accessKeyId: this._access_key, 29 | secretAccessKey: this._secret_key, 30 | }, 31 | }) 32 | } 33 | 34 | /** 35 | * Generate keyfile 36 | */ 37 | private _generateKeyfile(values: string[]) { 38 | return values.join('/') 39 | } 40 | 41 | /** 42 | * Get expires object 43 | */ 44 | public expiresObject() { 45 | const getExpired = this._expires.replace(/[^0-9]/g, '') 46 | 47 | const expiresIn = ms(this._expires) 48 | const expiryDate = addDays(new Date(), Number(getExpired)) 49 | 50 | return { expiresIn, expiryDate } 51 | } 52 | 53 | /** 54 | * Initialize storage 55 | */ 56 | async initialize() { 57 | const msgType = `${green('storage - aws s3')}` 58 | const bucketName = this._bucket 59 | 60 | try { 61 | const command = new S3Client.GetBucketAclCommand({ Bucket: bucketName }) 62 | const data = await this.client.send(command) 63 | 64 | const message = `${msgType} - ${bucketName} bucket found` 65 | logger.info(message) 66 | console.log(data.Grants) 67 | } catch (error: any) { 68 | const message = `${msgType} - ${bucketName} bucket not found` 69 | logger.error(message) 70 | // create bucket if not exists 71 | await this._createBucket() 72 | } 73 | } 74 | 75 | /** 76 | * Create bucket 77 | */ 78 | private async _createBucket() { 79 | const msgType = `${green('storage - aws s3')}` 80 | const bucketName = this._bucket 81 | 82 | try { 83 | const command = new S3Client.CreateBucketCommand({ Bucket: bucketName }) 84 | const data = await this.client.send(command) 85 | 86 | const message = `${msgType} - ${bucketName} bucket created` 87 | logger.info(message) 88 | console.log(data) 89 | } catch (error: any) { 90 | const message = `${msgType} error: ${error.message ?? error}` 91 | logger.error(message) 92 | process.exit(1) 93 | } 94 | } 95 | 96 | /** 97 | * Upload file 98 | */ 99 | async uploadFile({ directory, file }: UploadFileParams) { 100 | const keyfile = this._generateKeyfile([directory, file.filename]) 101 | 102 | const command = new S3Client.PutObjectCommand({ 103 | Bucket: this._bucket, 104 | Key: keyfile, 105 | Body: fs.createReadStream(file.path), 106 | ContentType: file.mimetype, // <-- this is what you need! 107 | ContentDisposition: `inline; filename=${file.filename}`, // <-- and this ! 108 | ACL: 'public-read', // <-- this makes it public so people can see it 109 | }) 110 | 111 | const data = await this.client.send(command) 112 | const signedUrl = await this.presignedUrl(keyfile) 113 | 114 | return { data, signedUrl } 115 | } 116 | 117 | /** 118 | * Generate presigned URL 119 | */ 120 | async presignedUrl(keyfile: string) { 121 | const msgType = `${green('storage - aws s3')}` 122 | const bucketName = this._bucket 123 | 124 | const { expiresIn } = this.expiresObject() 125 | const newExpiresIn = expiresIn / 1000 126 | 127 | const command = new S3Client.GetObjectCommand({ 128 | Bucket: bucketName, 129 | Key: keyfile, 130 | }) 131 | 132 | const signedUrl = await getSignedUrl(this.client, command, { 133 | expiresIn: newExpiresIn, 134 | }) 135 | 136 | const message = `${msgType} - ${keyfile} presigned URL generated` 137 | logger.info(message) 138 | 139 | return signedUrl 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/app/service/base.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { Attributes, Model, ModelStatic, NonNullFindOptions, Op } from 'sequelize' 3 | import { z } from 'zod' 4 | import ErrorResponse from '~/lib/http/errors' 5 | import { useQuery } from '~/lib/query-builder' 6 | import { validate } from '~/lib/validate' 7 | import { BaseServiceParams, DtoFindAll, FindParams } from './types' 8 | 9 | export default class BaseService { 10 | public repository: ModelStatic 11 | private _schema: z.ZodType 12 | protected _model: string 13 | 14 | constructor({ repository, schema, model }: BaseServiceParams) { 15 | this.repository = repository 16 | this._schema = schema 17 | this._model = model 18 | } 19 | 20 | /** 21 | * Find all 22 | */ 23 | async find({ page, pageSize, filtered = [], sorted = [] }: FindParams): Promise> { 24 | const query = useQuery({ 25 | model: this.repository, 26 | reqQuery: { page, pageSize, filtered, sorted }, 27 | includeRule: [], 28 | }) 29 | 30 | const data = await this.repository.findAll({ 31 | ...query, 32 | order: query.order ? query.order : [['created_at', 'desc']], 33 | }) 34 | 35 | const total = await this.repository.count({ 36 | include: query.includeCount, 37 | where: query.where, 38 | }) 39 | 40 | return { data, total } 41 | } 42 | 43 | /** 44 | * Find one 45 | */ 46 | protected async _findOne(options: NonNullFindOptions>): Promise { 47 | const record = await this.repository.findOne(options) 48 | 49 | if (!record) { 50 | throw new ErrorResponse.NotFound(`${this._model} not found`) 51 | } 52 | 53 | return record 54 | } 55 | 56 | /** 57 | * Find by id 58 | */ 59 | async findById(id: string, options?: NonNullFindOptions>): Promise { 60 | const newId = validate.uuid(id) 61 | 62 | // @ts-expect-error 63 | return this._findOne({ where: { id: newId }, ...options }) 64 | } 65 | 66 | /** 67 | * Create 68 | */ 69 | async create(data: T): Promise { 70 | const values = this._schema.parse(data) 71 | return this.repository.create(values) 72 | } 73 | 74 | /** 75 | * Update 76 | */ 77 | async update(id: string, data: T): Promise { 78 | const record = await this.findById(id) 79 | 80 | const values = this._schema.parse({ ...record, ...data }) 81 | return record.update({ ...record, ...values }) 82 | } 83 | 84 | /** 85 | * Restore 86 | */ 87 | async restore(id: string) { 88 | const record = await this.findById(id, { rejectOnEmpty: true, paranoid: true }) 89 | 90 | // @ts-expect-error 91 | await this.repository.restore({ where: { id: record.id } }) 92 | } 93 | 94 | /** 95 | * Soft delete 96 | */ 97 | async softDelete(id: string) { 98 | const record = await this.findById(id, { rejectOnEmpty: true, paranoid: true }) 99 | 100 | // @ts-expect-error 101 | await this.repository.destroy({ where: { id: record.id }, force: false }) 102 | } 103 | 104 | /** 105 | * Force delete 106 | */ 107 | async forceDelete(id: string) { 108 | const record = await this.findById(id, { rejectOnEmpty: true, paranoid: true }) 109 | 110 | // @ts-expect-error 111 | await this.repository.destroy({ where: { id: record.id }, force: true }) 112 | } 113 | 114 | /** 115 | * Validate ids 116 | */ 117 | private _validateIds(ids: string[]): string[] { 118 | if (_.isEmpty(ids)) { 119 | throw new ErrorResponse.BadRequest('ids is required') 120 | } 121 | 122 | return ids.map(validate.uuid) 123 | } 124 | 125 | /** 126 | * Multiple restore 127 | */ 128 | async multipleRestore(ids: string[]) { 129 | const newIds = this._validateIds(ids) 130 | 131 | // @ts-expect-error 132 | await this.repository.restore({ where: { id: { [Op.in]: newIds } }, paranoid: true }) 133 | } 134 | 135 | /** 136 | * Multiple soft delete 137 | */ 138 | async multipleSoftDelete(ids: string[]) { 139 | const newIds = this._validateIds(ids) 140 | 141 | // @ts-expect-error 142 | await this.repository.destroy({ where: { id: { [Op.in]: newIds } }, force: false }) 143 | } 144 | 145 | /** 146 | * Multiple force delete 147 | */ 148 | async multipleForceDelete(ids: string[]) { 149 | const newIds = this._validateIds(ids) 150 | 151 | // @ts-expect-error 152 | await this.repository.destroy({ where: { id: { [Op.in]: newIds } }, force: true }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/app/handler/upload.ts: -------------------------------------------------------------------------------- 1 | import express, { NextFunction, Request, Response } from 'express' 2 | import _ from 'lodash' 3 | import { asyncHandler } from '~/lib/async-handler' 4 | import { deleteFile } from '~/lib/fs/delete-file' 5 | import HttpResponse from '~/lib/http/response' 6 | import { FileParams } from '~/lib/storage/types' 7 | import { useMulter } from '~/lib/upload/multer' 8 | import authorization from '../middleware/authorization' 9 | import UploadService from '../service/upload' 10 | 11 | const route = express.Router() 12 | const service = new UploadService() 13 | 14 | const uploadFile = useMulter({ 15 | dest: 'public/uploads/temp', 16 | }).fields([{ name: 'file_upload', maxCount: 1 }]) 17 | 18 | const setFileToBody = asyncHandler(async (req: Request, _res: Response, next: NextFunction) => { 19 | const file_upload = req.pickSingleFieldMulter(['file_upload']) 20 | req.setBody(file_upload) 21 | next() 22 | }) 23 | 24 | route.get( 25 | '/', 26 | authorization(), 27 | asyncHandler(async (req: Request, res: Response) => { 28 | const { page, pageSize, filtered, sorted } = req.getQuery() 29 | const records = await service.find({ page, pageSize, filtered, sorted }) 30 | const httpResponse = HttpResponse.get({ data: records }) 31 | res.status(200).json(httpResponse) 32 | }) 33 | ) 34 | 35 | route.get( 36 | '/:id', 37 | authorization(), 38 | asyncHandler(async (req: Request, res: Response) => { 39 | const { id } = req.getParams() 40 | const record = await service.findById(id) 41 | const httpResponse = HttpResponse.get({ data: record }) 42 | res.status(200).json(httpResponse) 43 | }) 44 | ) 45 | 46 | route.post( 47 | '/', 48 | authorization(), 49 | uploadFile, 50 | setFileToBody, 51 | asyncHandler(async (req: Request, res: Response) => { 52 | const formValues = req.getBody() 53 | const file_upload = _.get(formValues, 'file_upload', {}) as FileParams 54 | 55 | let data 56 | 57 | if (!_.isEmpty(file_upload) && !_.isEmpty(file_upload.path)) { 58 | const directory = formValues.directory || 'uploads' 59 | 60 | data = await service.uploadFile({ file: file_upload, directory }) 61 | 62 | // delete file after upload to object storage 63 | deleteFile(file_upload.path) 64 | } 65 | 66 | const httpResponse = HttpResponse.created({ data: data?.upload, storage: data?.storage }) 67 | res.status(201).json(httpResponse) 68 | }) 69 | ) 70 | 71 | route.get( 72 | '/presigned-url', 73 | authorization(), 74 | asyncHandler(async (req: Request, res: Response) => { 75 | const { keyfile } = req.getBody() 76 | const record = await service.findWithPresignedUrl(keyfile) 77 | const httpResponse = HttpResponse.get({ data: record }) 78 | res.status(200).json(httpResponse) 79 | }) 80 | ) 81 | 82 | route.put( 83 | '/:id', 84 | authorization(), 85 | uploadFile, 86 | setFileToBody, 87 | asyncHandler(async (req: Request, res: Response) => { 88 | const { id } = req.getParams() 89 | const formValues = req.getBody() 90 | const file_upload = _.get(formValues, 'file_upload', {}) as FileParams 91 | 92 | let data 93 | 94 | if (!_.isEmpty(file_upload) && !_.isEmpty(file_upload.path)) { 95 | const directory = formValues.directory || 'uploads' 96 | 97 | data = await service.uploadFile({ file: file_upload, directory, upload_id: id }) 98 | 99 | // delete file after upload to object storage 100 | deleteFile(file_upload.path) 101 | } 102 | 103 | const record = await service.update(id, formValues) 104 | const httpResponse = HttpResponse.updated({ data: record, storage: data?.storage }) 105 | res.status(200).json(httpResponse) 106 | }) 107 | ) 108 | 109 | route.put( 110 | '/restore/:id', 111 | authorization(), 112 | asyncHandler(async (req: Request, res: Response) => { 113 | const { id } = req.getParams() 114 | await service.restore(id) 115 | const httpResponse = HttpResponse.updated({}) 116 | res.status(200).json(httpResponse) 117 | }) 118 | ) 119 | 120 | route.delete( 121 | '/soft-delete/:id', 122 | authorization(), 123 | asyncHandler(async (req: Request, res: Response) => { 124 | const { id } = req.getParams() 125 | await service.softDelete(id) 126 | const httpResponse = HttpResponse.deleted({}) 127 | res.status(200).json(httpResponse) 128 | }) 129 | ) 130 | 131 | route.delete( 132 | '/force-delete/:id', 133 | authorization(), 134 | asyncHandler(async (req: Request, res: Response) => { 135 | const { id } = req.getParams() 136 | await service.forceDelete(id) 137 | const httpResponse = HttpResponse.deleted({}) 138 | res.status(200).json(httpResponse) 139 | }) 140 | ) 141 | 142 | export { route as UploadHandler } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Express API with Sequelize 2 | 3 | [![Documentation](https://img.shields.io/badge/Documentation-yes-brightgreen.svg)](https://github.com/masb0ymas/express-api-sequelize#readme) 4 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/masb0ymas/express-api-sequelize/graphs/commit-activity) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/masb0ymas/express-api-sequelize/blob/master/LICENSE.md) 6 | [![Version](https://img.shields.io/badge/Version-6.0.0-blue.svg?cacheSeconds=2592000)](https://github.com/masb0ymas/express-api-sequelize/releases/tag/v6.0.0) 7 | [![Express](https://img.shields.io/badge/Express-4.21.2-informational?logo=express&color=22272E)](https://expressjs.com/) 8 | 9 | ![Node](https://badges.aleen42.com/src/node.svg) 10 | ![Eslint](https://badges.aleen42.com/src/eslint.svg) 11 | ![TypeScript](https://badges.aleen42.com/src/typescript.svg) 12 | ![Docker](https://badges.aleen42.com/src/docker.svg) 13 | 14 | A robust Express API template with TypeScript, Sequelize ORM, and comprehensive tooling for building production-ready applications. 15 | Base API using [express-api](https://github.com/masb0ymas/express-api) 16 | 17 | ## Features 18 | 19 | - **[TypeScript](https://github.com/microsoft/TypeScript)** `5.8.x` - Type-safe JavaScript 20 | - **[Sequelize](https://github.com/sequelize/sequelize)** `6.x` - Powerful ORM for SQL databases 21 | - **[Express](https://expressjs.com/)** `4.21.x` - Fast, unopinionated web framework 22 | - **[Nodemailer](https://github.com/nodemailer/nodemailer)** `6.x` - Email sending made simple 23 | - **[Zod](https://github.com/colinhacks/zod)** `3.x` - TypeScript-first schema validation 24 | - **[PostgreSQL](https://www.postgresql.org/)** - Advanced open source database 25 | - **Code Quality** 26 | - JavaScript Style with [Standard with TypeScript](https://github.com/standard/eslint-config-standard-with-typescript) 27 | - Code formatting with [Prettier](https://github.com/prettier/prettier) 28 | - [ESLint](https://github.com/prettier/eslint-config-prettier) and [TypeScript ESLint](https://github.com/typescript-eslint/typescript-eslint) integration 29 | - **API Documentation** with [Swagger](https://github.com/swagger-api/swagger-ui) OpenAPI `3.x` 30 | - **Logging** with [Pino](https://github.com/pinojs/pino) 31 | - **Containerization** with [Docker](https://www.docker.com/) 32 | 33 | ## Prerequisites 34 | 35 | - Node.js >= 20.x 36 | - PostgreSQL 37 | - Docker (optional) 38 | 39 | ## Module System 40 | 41 | - By default, the `main` branch uses CommonJs (`type: commonjs`) 42 | - For ES Module pending implementation because of Sequelize issue. 43 | 44 | ## Getting Started 45 | 46 | 1. **Clone the repository** 47 | 48 | ```bash 49 | git clone https://github.com/masb0ymas/express-api-sequelize.git 50 | cd express-api-sequelize 51 | ``` 52 | 53 | 2. **Set up environment variables** 54 | 55 | ```bash 56 | cp .env.example .env 57 | ``` 58 | 59 | Then configure database settings in the `.env` file. 60 | 61 | or you can generate .env with command: 62 | 63 | ```bash 64 | yarn secret 65 | ``` 66 | 67 | 3. **Install dependencies** 68 | 69 | ```bash 70 | yarn install 71 | ``` 72 | 73 | 4. **Set up database** 74 | 75 | ```bash 76 | yarn db:create && yarn db:reset 77 | ``` 78 | 79 | Or create your database manually 80 | 81 | 5. **Start development server** 82 | 83 | ```bash 84 | yarn dev 85 | ``` 86 | 87 | With file watching: 88 | 89 | ```bash 90 | yarn dev:watch 91 | ``` 92 | 93 | ## Deployment 94 | 95 | ### Release Process 96 | 97 | ```bash 98 | yarn release 99 | ``` 100 | 101 | ### Docker Deployment 102 | 103 | ```bash 104 | # Build the Docker image 105 | docker build -t yourname/express:v1.0.0 . 106 | 107 | # Run the container 108 | docker run -p 7000:8000 -d yourname/express:v1.0.0 109 | ``` 110 | 111 | ## Scripts 112 | 113 | - `npm run dev` - Start development server with hot reloading 114 | - `npm run build` - Build for production 115 | - `npm run start` - Start production server 116 | - `npm run db:create` - Create database 117 | - `npm run db:reset` - Reset database schema 118 | - `npm run release` - Release a new version 119 | 120 | ## Author 121 | 122 | [![Github](https://badges.aleen42.com/src/github.svg)](https://github.com/masb0ymas) 123 | [![Twitter](https://badges.aleen42.com/src/twitter.svg)](https://twitter.com/masb0ymas) 124 | [![LinkedIn](https://img.shields.io/badge/LinkedIn-Informational?logo=linkedin&color=0A66C2&logoColor=white)](https://www.linkedin.com/in/masb0ymas) 125 | 126 | ## Support 127 | 128 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I03MVAI) 129 | 130 | [](https://trakteer.id/masb0ymas) 131 | 132 | [](https://www.paypal.com/paypalme/masb0ymas) 133 | -------------------------------------------------------------------------------- /src/lib/query-builder/sqlize-query.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Includeable, IncludeOptions, Model } from 'sequelize' 2 | import { QueryHelper } from './query-helper' 3 | import { TransformHelper } from './transform-helper' 4 | 5 | type TValueParser = (value: any) => any 6 | type TTransformBuild = (value: any, transformHelper: TransformHelper) => any 7 | type TQueryBuilder = (value: any, queryHelper: QueryHelper) => any 8 | 9 | type TCustomIncludeOptions = IncludeOptions & { key?: string } 10 | type TOnBuildInclude = (value: TCustomIncludeOptions) => TCustomIncludeOptions 11 | 12 | /** 13 | * Get primitive data type based on Sequelize DataType 14 | * @param dataType - Sequelize DataType 15 | * @returns Primitive representation as 'string' or 0 16 | */ 17 | export function getPrimitiveDataType(dataType: T): 'string' | 0 { 18 | const findDataType = (item: any): boolean => dataType instanceof item 19 | 20 | const stringTypes = [ 21 | DataTypes.JSON, 22 | DataTypes.TEXT, 23 | DataTypes.STRING, 24 | DataTypes.UUID, 25 | DataTypes.UUIDV1, 26 | DataTypes.UUIDV4, 27 | ] 28 | 29 | const numberTypes = [ 30 | DataTypes.REAL, 31 | DataTypes.INTEGER, 32 | DataTypes.FLOAT, 33 | DataTypes.BIGINT, 34 | DataTypes.DECIMAL, 35 | DataTypes.DOUBLE, 36 | DataTypes.MEDIUMINT, 37 | DataTypes.NUMBER, 38 | DataTypes.SMALLINT, 39 | DataTypes.TINYINT, 40 | ] 41 | 42 | if (stringTypes.some(findDataType)) { 43 | return 'string' 44 | } 45 | 46 | if (numberTypes.some(findDataType)) { 47 | return 0 48 | } 49 | 50 | // Default is string for all other types 51 | return 'string' 52 | } 53 | 54 | /** 55 | * Transform Sequelize includes to queryable format 56 | * @param includes - Array of Sequelize includeable objects 57 | * @param onBuildInclude - Optional callback to modify each include 58 | * @returns Array of custom include options 59 | */ 60 | export function transfromIncludeToQueryable( 61 | includes: Includeable[], 62 | onBuildInclude?: TOnBuildInclude 63 | ): TCustomIncludeOptions[] { 64 | const result: TCustomIncludeOptions[] = [] 65 | const _onBuildInclude = onBuildInclude ?? ((value: TCustomIncludeOptions) => value) 66 | 67 | function processIncludes(includes: Includeable[], parent?: IncludeOptions): void { 68 | for (const include of includes) { 69 | const customInclude = include as TCustomIncludeOptions 70 | const { model, key, include: nestedIncludes, ...restInclude } = customInclude 71 | 72 | const isTypeModel = typeof Model === typeof include 73 | const curModel = (isTypeModel ? include : model) as typeof Model 74 | const defaultName = curModel.options.name?.singular 75 | 76 | const processedInclude = _onBuildInclude({ 77 | ...(isTypeModel ? {} : restInclude), 78 | key: key ?? defaultName, 79 | model: curModel, 80 | } as unknown as TCustomIncludeOptions) 81 | 82 | if (parent) { 83 | parent.include = parent.include ?? [] 84 | parent.include.push(processedInclude) 85 | } else { 86 | result.push(processedInclude) 87 | } 88 | 89 | if (nestedIncludes) { 90 | processIncludes(nestedIncludes, processedInclude) 91 | } 92 | } 93 | } 94 | 95 | processIncludes(includes) 96 | return result 97 | } 98 | 99 | export default class SqlizeQuery { 100 | private readonly _valueParsers: TValueParser[] = [] 101 | private readonly _transformBuilds: TTransformBuild[] = [] 102 | private readonly _queryBuilders: TQueryBuilder[] = [] 103 | 104 | /** 105 | * Add a value parser function 106 | */ 107 | public addValueParser(fn: TValueParser): void { 108 | this._valueParsers.push(fn) 109 | } 110 | 111 | /** 112 | * Add a query builder function 113 | */ 114 | public addQueryBuilder(fn: TQueryBuilder): void { 115 | this._queryBuilders.push(fn) 116 | } 117 | 118 | /** 119 | * Add a transform build function 120 | */ 121 | public addTransformBuild(fn: TTransformBuild): void { 122 | this._transformBuilds.push(fn) 123 | } 124 | 125 | /** 126 | * Build the query by applying parsers, builders and transforms 127 | * @param value - Input value to process 128 | * @returns Processed query result 129 | */ 130 | public build(value: any): any { 131 | // Apply all value parsers 132 | let parsedValue = value 133 | for (const parser of this._valueParsers) { 134 | parsedValue = parser(parsedValue) 135 | } 136 | 137 | // Apply query builders 138 | const queryHelper = new QueryHelper(Array.isArray(parsedValue) ? parsedValue : []) 139 | const valueArray = Array.isArray(parsedValue) && parsedValue.length ? parsedValue : [undefined] 140 | 141 | for (const item of valueArray) { 142 | for (const builder of this._queryBuilders) { 143 | builder(item, queryHelper) 144 | } 145 | } 146 | 147 | // Apply transform builds 148 | const result = queryHelper.getQuery() 149 | const transformHelper = new TransformHelper(result) 150 | 151 | for (const transform of this._transformBuilds) { 152 | transform(result, transformHelper) 153 | } 154 | 155 | return transformHelper.getValue() 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/lib/storage/gcs.ts: -------------------------------------------------------------------------------- 1 | import * as GCS from '@google-cloud/storage' 2 | import { green } from 'colorette' 3 | import { addDays } from 'date-fns' 4 | import fs from 'fs' 5 | import path from 'path' 6 | import { logger } from '~/config/logger' 7 | import { storageExists } from '../boolean' 8 | import { ms } from '../date' 9 | import { currentDir } from '../string' 10 | import { GoogleCloudStorageParams, UploadFileParams } from './types' 11 | 12 | export default class GoogleCloudStorage { 13 | public client: GCS.Storage 14 | 15 | private _access_key: string 16 | private _filepath: string 17 | private _bucket: string 18 | private _expires: string 19 | 20 | constructor(params: GoogleCloudStorageParams) { 21 | this._access_key = params.access_key 22 | this._bucket = params.bucket 23 | this._expires = params.expires 24 | this._filepath = path.resolve(`${currentDir}/${params.filepath}`) 25 | 26 | const isStorageEnabled = storageExists() 27 | const msgType = `${green('storage - google cloud storage')}` 28 | 29 | if (isStorageEnabled && !this._access_key && !fs.existsSync(this._filepath)) { 30 | const message = `${msgType} serviceAccount is missing on root directory` 31 | logger.error(message) 32 | 33 | throw new Error( 34 | 'Missing GCP Service Account!!!\nCopy gcp-serviceAccount from your console google to root directory "gcp-serviceAccount.json"' 35 | ) 36 | } 37 | 38 | if (this._access_key) { 39 | const message = `${msgType} - ${this._filepath}` 40 | logger.info(message) 41 | } 42 | 43 | this.client = new GCS.Storage({ 44 | projectId: this._access_key, 45 | keyFilename: this._filepath, 46 | }) 47 | } 48 | 49 | /** 50 | * Generate keyfile 51 | */ 52 | private _generateKeyfile(values: string[]) { 53 | return values.join('/') 54 | } 55 | 56 | /** 57 | * Get expires object 58 | */ 59 | public expiresObject() { 60 | const getExpired = this._expires.replace(/[^0-9]/g, '') 61 | 62 | const expiresIn = ms(this._expires) 63 | const expiryDate = addDays(new Date(), Number(getExpired)) 64 | 65 | return { expiresIn, expiryDate } 66 | } 67 | 68 | /** 69 | * Initialize storage 70 | */ 71 | async initialize() { 72 | const msgType = `${green('storage - google cloud storage')}` 73 | const bucketName = this._bucket 74 | 75 | try { 76 | const data = this.client.bucket(bucketName) 77 | const getBucket = await data.exists() 78 | const getMetadata = await data.getMetadata() 79 | 80 | if (getBucket[0]) { 81 | const message = `${msgType} - ${bucketName} bucket found` 82 | logger.info(message) 83 | console.log(getMetadata[0]) 84 | } 85 | } catch (error) { 86 | const message = `${msgType} - ${bucketName} bucket not found` 87 | logger.error(message) 88 | // create bucket if not exists 89 | await this._createBucket() 90 | } 91 | } 92 | 93 | /** 94 | * Create bucket 95 | */ 96 | private async _createBucket() { 97 | const msgType = `${green('storage - google cloud storage')}` 98 | const bucketName = this._bucket 99 | 100 | try { 101 | const data = await this.client.createBucket(bucketName) 102 | const getMetadata = await data[0].getMetadata() 103 | 104 | const message = `${msgType} - ${bucketName} bucket created` 105 | logger.info(message) 106 | console.log(getMetadata[0]) 107 | } catch (error: any) { 108 | const message = `${msgType} error: ${error.message ?? error}` 109 | logger.error(message) 110 | process.exit(1) 111 | } 112 | } 113 | 114 | /** 115 | * Upload file 116 | */ 117 | async uploadFile({ directory, file }: UploadFileParams) { 118 | const keyfile = this._generateKeyfile([directory, file.filename]) 119 | 120 | // For a destination object that does not yet exist, 121 | // set the ifGenerationMatch precondition to 0 122 | // If the destination object already exists in your bucket, set instead a 123 | // generation-match precondition using its generation number. 124 | const generationMatchPrecondition = 0 125 | 126 | const options: GCS.UploadOptions = { 127 | destination: keyfile, 128 | preconditionOpts: { ifGenerationMatch: generationMatchPrecondition }, 129 | } 130 | 131 | const data = await this.client.bucket(this._bucket).upload(file.path, options) 132 | const signedUrl = await this.presignedUrl(keyfile) 133 | 134 | return { data: data[1], signedUrl } 135 | } 136 | 137 | /** 138 | * Generate presigned URL 139 | */ 140 | async presignedUrl(keyfile: string) { 141 | const msgType = `${green('storage - google cloud storage')}` 142 | const bucketName = this._bucket 143 | 144 | const { expiresIn } = this.expiresObject() 145 | const options: GCS.GetSignedUrlConfig = { 146 | version: 'v4', 147 | action: 'read', 148 | virtualHostedStyle: true, 149 | expires: Date.now() + expiresIn, 150 | } 151 | 152 | const data = await this.client.bucket(bucketName).file(keyfile).getSignedUrl(options) 153 | const signedUrl = data[0] 154 | 155 | const message = `${msgType} - ${keyfile} presigned URL generated` 156 | logger.info(message) 157 | 158 | return signedUrl 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /public/swagger/routes/role.json: -------------------------------------------------------------------------------- 1 | { 2 | "/v1/role": { 3 | "get": { 4 | "tags": ["Role"], 5 | "summary": "Get All Role", 6 | "security": [{ "auth_token": [] }], 7 | "parameters": [ 8 | { "$ref": "#/components/parameters/page" }, 9 | { "$ref": "#/components/parameters/pageSize" }, 10 | { "$ref": "#/components/parameters/filtered" }, 11 | { "$ref": "#/components/parameters/sorted" } 12 | ], 13 | "responses": { 14 | "200": { "description": "Find all records" }, 15 | "400": { "description": "Something went wrong" }, 16 | "500": { "description": "Internal Server Error" } 17 | } 18 | }, 19 | "post": { 20 | "tags": ["Role"], 21 | "summary": "Create New Role", 22 | "security": [{ "auth_token": [] }], 23 | "requestBody": { 24 | "required": true, 25 | "content": { 26 | "application/x-www-form-urlencoded": { 27 | "schema": { 28 | "type": "object", 29 | "properties": { 30 | "name": { 31 | "type": "string" 32 | } 33 | }, 34 | "required": ["name"] 35 | } 36 | } 37 | } 38 | }, 39 | "responses": { 40 | "201": { "description": "Create new records" }, 41 | "400": { "description": "Something went wrong" }, 42 | "422": { "description": "Unprocessable Entity" }, 43 | "500": { "description": "Internal Server Error" } 44 | } 45 | } 46 | }, 47 | "/v1/role/{id}": { 48 | "get": { 49 | "tags": ["Role"], 50 | "summary": "Get Role By Id", 51 | "security": [{ "auth_token": [] }], 52 | "parameters": [ 53 | { 54 | "in": "path", 55 | "name": "id", 56 | "required": true, 57 | "schema": { 58 | "type": "string" 59 | }, 60 | "description": "Role Id" 61 | } 62 | ], 63 | "responses": { 64 | "200": { "description": "Get record by id" }, 65 | "400": { "description": "Something went wrong" }, 66 | "404": { "description": "Record not found" }, 67 | "500": { "description": "Internal Server Error" } 68 | } 69 | }, 70 | "put": { 71 | "tags": ["Role"], 72 | "summary": "Update Data Role", 73 | "security": [{ "auth_token": [] }], 74 | "parameters": [ 75 | { 76 | "in": "path", 77 | "name": "id", 78 | "required": true, 79 | "schema": { 80 | "type": "string" 81 | }, 82 | "description": "Role Id" 83 | } 84 | ], 85 | "requestBody": { 86 | "required": true, 87 | "content": { 88 | "application/x-www-form-urlencoded": { 89 | "schema": { 90 | "type": "object", 91 | "properties": { 92 | "name": { 93 | "type": "string" 94 | } 95 | }, 96 | "required": ["name"] 97 | } 98 | } 99 | } 100 | }, 101 | "responses": { 102 | "200": { "description": "Update record by id" }, 103 | "400": { "description": "Something went wrong" }, 104 | "404": { "description": "Record not found" }, 105 | "422": { "description": "Unprocessable Entity" }, 106 | "500": { "description": "Internal Server Error" } 107 | } 108 | } 109 | }, 110 | "/v1/role/restore/{id}": { 111 | "put": { 112 | "tags": ["Role"], 113 | "summary": "Restore Role By Id", 114 | "security": [{ "auth_token": [] }], 115 | "parameters": [ 116 | { 117 | "in": "path", 118 | "name": "id", 119 | "required": true, 120 | "schema": { 121 | "type": "string" 122 | }, 123 | "description": "Role Id" 124 | } 125 | ], 126 | "responses": { 127 | "200": { "description": "Restore record by id" }, 128 | "400": { "description": "Something went wrong" }, 129 | "404": { "description": "Record not found" }, 130 | "500": { "description": "Internal Server Error" } 131 | } 132 | } 133 | }, 134 | "/v1/role/soft-delete/{id}": { 135 | "delete": { 136 | "tags": ["Role"], 137 | "summary": "Soft Delete Role By Id", 138 | "security": [{ "auth_token": [] }], 139 | "parameters": [ 140 | { 141 | "in": "path", 142 | "name": "id", 143 | "required": true, 144 | "schema": { 145 | "type": "string" 146 | }, 147 | "description": "Role Id" 148 | } 149 | ], 150 | "responses": { 151 | "200": { "description": "Soft Delete record by id" }, 152 | "400": { "description": "Something went wrong" }, 153 | "404": { "description": "Record not found" }, 154 | "500": { "description": "Internal Server Error" } 155 | } 156 | } 157 | }, 158 | "/v1/role/force-delete/{id}": { 159 | "delete": { 160 | "tags": ["Role"], 161 | "summary": "Force Delete Role By Id ( Forever )", 162 | "security": [{ "auth_token": [] }], 163 | "parameters": [ 164 | { 165 | "in": "path", 166 | "name": "id", 167 | "required": true, 168 | "schema": { 169 | "type": "string" 170 | }, 171 | "description": "Role Id" 172 | } 173 | ], 174 | "responses": { 175 | "200": { "description": "Force Delete record by id ( Forever )" }, 176 | "400": { "description": "Something went wrong" }, 177 | "404": { "description": "Record not found" }, 178 | "500": { "description": "Internal Server Error" } 179 | } 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/app/service/auth.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { Model, ModelStatic } from 'sequelize' 3 | import { v4 as uuidv4 } from 'uuid' 4 | import { env } from '~/config/env' 5 | import { mailExists } from '~/lib/boolean' 6 | import { ConstRole } from '~/lib/constant/seed/role' 7 | import ErrorResponse from '~/lib/http/errors' 8 | import { SendEmailRegistration } from '~/lib/smtp/template/auth' 9 | import JwtToken from '~/lib/token/jwt' 10 | import { validate } from '~/lib/validate' 11 | import { db } from '../database/connection' 12 | import Role from '../database/entity/role' 13 | import Session from '../database/entity/session' 14 | import User from '../database/entity/user' 15 | import { LoginSchema, loginSchema, UserLoginState, userSchema } from '../database/schema/user' 16 | import SessionService from './session' 17 | 18 | type UserModel = User & Model 19 | type RoleModel = Role & Model 20 | type SessionModel = Session & Model 21 | 22 | type VerifySessionParams = { 23 | user_id: string 24 | token: string 25 | } 26 | 27 | type LogoutParams = VerifySessionParams 28 | 29 | const jwt = new JwtToken({ secret: env.JWT_SECRET, expires: env.JWT_EXPIRES }) 30 | const sessionService = new SessionService() 31 | 32 | export default class AuthService { 33 | private _repository: { 34 | user: ModelStatic 35 | role: ModelStatic 36 | session: ModelStatic 37 | } 38 | 39 | constructor() { 40 | this._repository = { 41 | user: User as unknown as ModelStatic, 42 | role: Role as unknown as ModelStatic, 43 | session: Session as unknown as ModelStatic, 44 | } 45 | } 46 | 47 | /** 48 | * Register new user 49 | */ 50 | async register(formData: any) { 51 | const uuid = uuidv4() 52 | const isMailEnabled = mailExists() 53 | 54 | const payload = JSON.parse(JSON.stringify({ uid: uuid })) 55 | const { token } = jwt.generate(payload) 56 | 57 | const values = userSchema.parse({ 58 | ...formData, 59 | is_active: false, 60 | is_blocked: false, 61 | phone: validate.empty(formData.phone), 62 | token_verify: token, 63 | role_id: ConstRole.ID_USER, 64 | upload_id: null, 65 | }) 66 | 67 | // @ts-expect-error 68 | const formRegister: User = { ...values, password: validate.empty(formData.new_password) } 69 | const data = await this._repository.user.create({ ...formRegister }) 70 | 71 | if (isMailEnabled) { 72 | await SendEmailRegistration({ 73 | fullname: values.fullname, 74 | email: values.email, 75 | url_token: token, 76 | }) 77 | } 78 | 79 | return data 80 | } 81 | 82 | /** 83 | * Login user 84 | */ 85 | async login(formData: LoginSchema) { 86 | const values = loginSchema.parse(formData) 87 | 88 | let data: any 89 | 90 | await db.sequelize!.transaction(async (transaction) => { 91 | const repo = { 92 | user: this._repository.user, 93 | role: this._repository.role, 94 | session: this._repository.session, 95 | } 96 | 97 | const getUser = await repo.user.findOne({ 98 | attributes: ['id', 'fullname', 'email', 'password', 'is_active', 'role_id'], 99 | where: { email: values.email }, 100 | transaction, 101 | }) 102 | 103 | if (!getUser) { 104 | throw new ErrorResponse.NotFound('user not found') 105 | } 106 | 107 | if (!getUser.is_active) { 108 | throw new ErrorResponse.BadRequest('user is not active, please verify your email') 109 | } 110 | 111 | const isPasswordMatch = await getUser.comparePassword(values.password) 112 | if (!isPasswordMatch) { 113 | throw new ErrorResponse.BadRequest('current password is incorrect') 114 | } 115 | 116 | const getRole = await repo.role.findOne({ where: { id: getUser.role_id }, transaction }) 117 | if (!getRole) { 118 | throw new ErrorResponse.NotFound('role not found') 119 | } 120 | 121 | const payload = JSON.parse(JSON.stringify({ uid: getUser.id })) 122 | const { token, expiresIn } = jwt.generate(payload) 123 | 124 | const formSession = { ...formData, user_id: getUser.id, token } 125 | await repo.session.create({ ...formSession }, { transaction }) 126 | 127 | const is_admin = [ConstRole.ID_ADMIN, ConstRole.ID_SUPER_ADMIN].includes(getRole.id) 128 | 129 | data = { 130 | fullname: getUser.fullname, 131 | email: getUser.email, 132 | uid: getUser.id, 133 | access_token: token, 134 | expires_at: new Date(Date.now() + expiresIn * 1000), 135 | expires_in: expiresIn, 136 | is_admin, 137 | } 138 | }) 139 | 140 | return data 141 | } 142 | 143 | /** 144 | * Verify user session 145 | */ 146 | async verifySession({ user_id, token }: VerifySessionParams) { 147 | const user = await this._repository.user.findOne({ where: { id: user_id } }) 148 | 149 | if (!user) { 150 | throw new ErrorResponse.NotFound('user not found') 151 | } 152 | 153 | const session = await sessionService.findByUserToken({ user_id, token }) 154 | const decodeToken = jwt.verify(token) 155 | const userToken = decodeToken.data as UserLoginState 156 | 157 | if (!_.isEmpty(userToken.uid) && userToken.uid !== user_id) { 158 | throw new ErrorResponse.BadRequest('user id not match') 159 | } 160 | 161 | return { ...user.toJSON(), session } 162 | } 163 | 164 | /** 165 | * Logout user 166 | */ 167 | async logout({ user_id, token }: LogoutParams) { 168 | const user = await this._repository.user.findOne({ where: { id: user_id } }) 169 | 170 | if (!user) { 171 | throw new ErrorResponse.NotFound('user not found') 172 | } 173 | 174 | await sessionService.deleteByUserToken({ user_id, token }) 175 | const message = 'logout successfully' 176 | 177 | return { message } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /public/swagger/routes/upload.json: -------------------------------------------------------------------------------- 1 | { 2 | "/v1/upload": { 3 | "get": { 4 | "tags": ["Upload"], 5 | "summary": "Get All Upload", 6 | "security": [{ "auth_token": [] }], 7 | "parameters": [ 8 | { "$ref": "#/components/parameters/page" }, 9 | { "$ref": "#/components/parameters/pageSize" }, 10 | { "$ref": "#/components/parameters/filtered" }, 11 | { "$ref": "#/components/parameters/sorted" } 12 | ], 13 | "responses": { 14 | "200": { "description": "Find all records" }, 15 | "400": { "description": "Something went wrong" }, 16 | "500": { "description": "Internal Server Error" } 17 | } 18 | }, 19 | "post": { 20 | "tags": ["Upload"], 21 | "summary": "Create New Upload", 22 | "security": [{ "auth_token": [] }], 23 | "requestBody": { 24 | "required": true, 25 | "content": { 26 | "multipart/form-data": { 27 | "schema": { 28 | "type": "object", 29 | "properties": { 30 | "file_upload": { 31 | "type": "string", 32 | "format": "binary" 33 | } 34 | }, 35 | "required": ["file_upload"] 36 | } 37 | } 38 | } 39 | }, 40 | "responses": { 41 | "201": { "description": "Create new records" }, 42 | "400": { "description": "Something went wrong" }, 43 | "422": { "description": "Unprocessable Entity" }, 44 | "500": { "description": "Internal Server Error" } 45 | } 46 | } 47 | }, 48 | "/v1/upload/{id}": { 49 | "get": { 50 | "tags": ["Upload"], 51 | "summary": "Get Upload By Id", 52 | "security": [{ "auth_token": [] }], 53 | "parameters": [ 54 | { 55 | "in": "path", 56 | "name": "id", 57 | "required": true, 58 | "schema": { 59 | "type": "string" 60 | }, 61 | "description": "Upload Id" 62 | } 63 | ], 64 | "responses": { 65 | "200": { "description": "Get record by id" }, 66 | "400": { "description": "Something went wrong" }, 67 | "404": { "description": "Record not found" }, 68 | "500": { "description": "Internal Server Error" } 69 | } 70 | }, 71 | "put": { 72 | "tags": ["Upload"], 73 | "summary": "Update Data Upload", 74 | "security": [{ "auth_token": [] }], 75 | "parameters": [ 76 | { 77 | "in": "path", 78 | "name": "id", 79 | "required": true, 80 | "schema": { 81 | "type": "string" 82 | }, 83 | "description": "Upload Id" 84 | } 85 | ], 86 | "requestBody": { 87 | "required": true, 88 | "content": { 89 | "multipart/form-data": { 90 | "schema": { 91 | "type": "object", 92 | "properties": { 93 | "file_upload": { 94 | "type": "string", 95 | "format": "binary" 96 | } 97 | }, 98 | "required": ["file_upload"] 99 | } 100 | } 101 | } 102 | }, 103 | "responses": { 104 | "200": { "description": "Update record by id" }, 105 | "400": { "description": "Something went wrong" }, 106 | "404": { "description": "Record not found" }, 107 | "422": { "description": "Unprocessable Entity" }, 108 | "500": { "description": "Internal Server Error" } 109 | } 110 | } 111 | }, 112 | "/v1/upload/restore/{id}": { 113 | "put": { 114 | "tags": ["Upload"], 115 | "summary": "Restore Upload By Id", 116 | "security": [{ "auth_token": [] }], 117 | "parameters": [ 118 | { 119 | "in": "path", 120 | "name": "id", 121 | "required": true, 122 | "schema": { 123 | "type": "string" 124 | }, 125 | "description": "Upload Id" 126 | } 127 | ], 128 | "responses": { 129 | "200": { "description": "Restore record by id" }, 130 | "400": { "description": "Something went wrong" }, 131 | "404": { "description": "Record not found" }, 132 | "500": { "description": "Internal Server Error" } 133 | } 134 | } 135 | }, 136 | "/v1/upload/soft-delete/{id}": { 137 | "delete": { 138 | "tags": ["Upload"], 139 | "summary": "Soft Delete Upload By Id", 140 | "security": [{ "auth_token": [] }], 141 | "parameters": [ 142 | { 143 | "in": "path", 144 | "name": "id", 145 | "required": true, 146 | "schema": { 147 | "type": "string" 148 | }, 149 | "description": "Upload Id" 150 | } 151 | ], 152 | "responses": { 153 | "200": { "description": "Soft Delete record by id" }, 154 | "400": { "description": "Something went wrong" }, 155 | "404": { "description": "Record not found" }, 156 | "500": { "description": "Internal Server Error" } 157 | } 158 | } 159 | }, 160 | "/v1/upload/force-delete/{id}": { 161 | "delete": { 162 | "tags": ["Upload"], 163 | "summary": "Force Delete Upload By Id ( Forever )", 164 | "security": [{ "auth_token": [] }], 165 | "parameters": [ 166 | { 167 | "in": "path", 168 | "name": "id", 169 | "required": true, 170 | "schema": { 171 | "type": "string" 172 | }, 173 | "description": "Upload Id" 174 | } 175 | ], 176 | "responses": { 177 | "200": { "description": "Force Delete record by id ( Forever )" }, 178 | "400": { "description": "Something went wrong" }, 179 | "404": { "description": "Record not found" }, 180 | "500": { "description": "Internal Server Error" } 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /public/swagger/routes/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "/v1/user": { 3 | "get": { 4 | "tags": ["User"], 5 | "summary": "Get All User", 6 | "security": [{ "auth_token": [] }], 7 | "parameters": [ 8 | { "$ref": "#/components/parameters/page" }, 9 | { "$ref": "#/components/parameters/pageSize" }, 10 | { "$ref": "#/components/parameters/filtered" }, 11 | { "$ref": "#/components/parameters/sorted" } 12 | ], 13 | "responses": { 14 | "200": { "description": "Find all records" }, 15 | "400": { "description": "Something went wrong" }, 16 | "500": { "description": "Internal Server Error" } 17 | } 18 | }, 19 | "post": { 20 | "tags": ["User"], 21 | "summary": "Create New User", 22 | "security": [{ "auth_token": [] }], 23 | "requestBody": { 24 | "required": true, 25 | "content": { 26 | "application/x-www-form-urlencoded": { 27 | "schema": { 28 | "type": "object", 29 | "properties": { 30 | "fullname": { 31 | "type": "string" 32 | }, 33 | "email": { 34 | "type": "string" 35 | }, 36 | "password": { 37 | "type": "string" 38 | }, 39 | "phone": { 40 | "type": "string" 41 | }, 42 | "token_verify": { 43 | "type": "string" 44 | }, 45 | "address": { 46 | "type": "string" 47 | }, 48 | "is_active": { 49 | "type": "boolean" 50 | }, 51 | "is_blocked": { 52 | "type": "boolean" 53 | }, 54 | "role_id": { 55 | "type": "string" 56 | }, 57 | "upload_id": { 58 | "type": "string" 59 | } 60 | }, 61 | "required": ["fullname", "email", "password", "role_id"] 62 | } 63 | } 64 | } 65 | }, 66 | "responses": { 67 | "201": { "description": "Create new records" }, 68 | "400": { "description": "Something went wrong" }, 69 | "422": { "description": "Unprocessable Entity" }, 70 | "500": { "description": "Internal Server Error" } 71 | } 72 | } 73 | }, 74 | "/v1/user/{id}": { 75 | "get": { 76 | "tags": ["User"], 77 | "summary": "Get User By Id", 78 | "security": [{ "auth_token": [] }], 79 | "parameters": [ 80 | { 81 | "in": "path", 82 | "name": "id", 83 | "required": true, 84 | "schema": { 85 | "type": "string" 86 | }, 87 | "description": "User Id" 88 | } 89 | ], 90 | "responses": { 91 | "200": { "description": "Get record by id" }, 92 | "400": { "description": "Something went wrong" }, 93 | "404": { "description": "Record not found" }, 94 | "500": { "description": "Internal Server Error" } 95 | } 96 | }, 97 | "put": { 98 | "tags": ["User"], 99 | "summary": "Update Data User", 100 | "security": [{ "auth_token": [] }], 101 | "parameters": [ 102 | { 103 | "in": "path", 104 | "name": "id", 105 | "required": true, 106 | "schema": { 107 | "type": "string" 108 | }, 109 | "description": "User Id" 110 | } 111 | ], 112 | "requestBody": { 113 | "required": true, 114 | "content": { 115 | "application/x-www-form-urlencoded": { 116 | "schema": { 117 | "type": "object", 118 | "properties": { 119 | "fullname": { 120 | "type": "string" 121 | }, 122 | "email": { 123 | "type": "string" 124 | }, 125 | "password": { 126 | "type": "string" 127 | }, 128 | "phone": { 129 | "type": "string" 130 | }, 131 | "token_verify": { 132 | "type": "string" 133 | }, 134 | "address": { 135 | "type": "string" 136 | }, 137 | "is_active": { 138 | "type": "boolean" 139 | }, 140 | "is_blocked": { 141 | "type": "boolean" 142 | }, 143 | "role_id": { 144 | "type": "string" 145 | }, 146 | "upload_id": { 147 | "type": "string" 148 | } 149 | }, 150 | "required": ["fullname", "email", "password", "role_id"] 151 | } 152 | } 153 | } 154 | }, 155 | "responses": { 156 | "200": { "description": "Update record by id" }, 157 | "400": { "description": "Something went wrong" }, 158 | "404": { "description": "Record not found" }, 159 | "422": { "description": "Unprocessable Entity" }, 160 | "500": { "description": "Internal Server Error" } 161 | } 162 | } 163 | }, 164 | "/v1/user/restore/{id}": { 165 | "put": { 166 | "tags": ["User"], 167 | "summary": "Restore User By Id", 168 | "security": [{ "auth_token": [] }], 169 | "parameters": [ 170 | { 171 | "in": "path", 172 | "name": "id", 173 | "required": true, 174 | "schema": { 175 | "type": "string" 176 | }, 177 | "description": "User Id" 178 | } 179 | ], 180 | "responses": { 181 | "200": { "description": "Restore record by id" }, 182 | "400": { "description": "Something went wrong" }, 183 | "404": { "description": "Record not found" }, 184 | "500": { "description": "Internal Server Error" } 185 | } 186 | } 187 | }, 188 | "/v1/user/soft-delete/{id}": { 189 | "delete": { 190 | "tags": ["User"], 191 | "summary": "Soft Delete User By Id", 192 | "security": [{ "auth_token": [] }], 193 | "parameters": [ 194 | { 195 | "in": "path", 196 | "name": "id", 197 | "required": true, 198 | "schema": { 199 | "type": "string" 200 | }, 201 | "description": "User Id" 202 | } 203 | ], 204 | "responses": { 205 | "200": { "description": "Soft Delete record by id" }, 206 | "400": { "description": "Something went wrong" }, 207 | "404": { "description": "Record not found" }, 208 | "500": { "description": "Internal Server Error" } 209 | } 210 | } 211 | }, 212 | "/v1/user/force-delete/{id}": { 213 | "delete": { 214 | "tags": ["User"], 215 | "summary": "Force Delete User By Id ( Forever )", 216 | "security": [{ "auth_token": [] }], 217 | "parameters": [ 218 | { 219 | "in": "path", 220 | "name": "id", 221 | "required": true, 222 | "schema": { 223 | "type": "string" 224 | }, 225 | "description": "User Id" 226 | } 227 | ], 228 | "responses": { 229 | "200": { "description": "Force Delete record by id ( Forever )" }, 230 | "400": { "description": "Something went wrong" }, 231 | "404": { "description": "Record not found" }, 232 | "500": { "description": "Internal Server Error" } 233 | } 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/lib/query-builder/index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { Includeable, IncludeOptions, ModelStatic, Op } from 'sequelize' 3 | import { validate as uuidValidate } from 'uuid' 4 | import { env } from '~/config/env' 5 | import SqlizeQuery, { getPrimitiveDataType, transfromIncludeToQueryable } from './sqlize-query' 6 | import { 7 | DtoSequelizeQuery, 8 | SequelizeConnectionOptions, 9 | SequelizeFilterIncludeHandledOnly, 10 | SequelizeGetFilteredQuery, 11 | SequelizeIncludeFilteredQuery, 12 | UseQuerySequelize, 13 | } from './types' 14 | 15 | /** 16 | * Parses a string value to JSON or returns the original value 17 | * @param value - The value to parse 18 | * @returns Parsed JSON object or original value 19 | */ 20 | function parserString(value: any): any { 21 | return typeof value === 'string' ? JSON.parse(value) : value || [] 22 | } 23 | 24 | /** 25 | * Extracts the exact query ID for a model based on prefix 26 | * @param id - The full ID string to process 27 | * @param prefixName - Optional prefix to filter by 28 | * @returns The extracted query ID or undefined if invalid 29 | */ 30 | function getExactQueryIdModel(id: string, prefixName?: string): string | undefined { 31 | if (id === undefined) { 32 | return undefined 33 | } 34 | 35 | const splitId = id.split('.') 36 | if (!prefixName && splitId.length > 1) { 37 | return undefined 38 | } 39 | 40 | const indexId = splitId.findIndex((str) => str === prefixName) 41 | if (prefixName && indexId < 0) { 42 | return undefined 43 | } 44 | 45 | const curId = prefixName 46 | ? splitId.filter((str, index) => index === indexId || index === indexId + 1).pop() 47 | : id 48 | 49 | if (!curId || (prefixName && splitId.indexOf(curId) !== splitId.length - 1)) { 50 | return undefined 51 | } 52 | 53 | return curId 54 | } 55 | 56 | /** 57 | * Creates a query builder for filtering data 58 | * @param params - Parameters for filtered query 59 | * @returns Configured SqlizeQuery instance 60 | */ 61 | function getFilteredQuery(params: SequelizeGetFilteredQuery): SqlizeQuery { 62 | const { model, prefixName, options } = params 63 | 64 | const sequelizeQuery = new SqlizeQuery() 65 | sequelizeQuery.addValueParser(parserString) 66 | 67 | sequelizeQuery.addQueryBuilder((filterData: { id: string; value: any }, queryHelper) => { 68 | const { id, value } = filterData || {} 69 | if (!id || value === undefined || value === null) return 70 | 71 | const curId = getExactQueryIdModel(id, prefixName) 72 | if (!curId) return 73 | 74 | const type = typeof getPrimitiveDataType(model?.rawAttributes?.[curId]?.type) 75 | 76 | if (type !== 'number') { 77 | if (uuidValidate(value)) { 78 | queryHelper.setQuery(curId, { [Op.eq]: value }) 79 | } else if (options?.dialect === 'postgres') { 80 | queryHelper.setQuery(curId, { [Op.iLike]: `%${value}%` }) 81 | } else { 82 | queryHelper.setQuery(curId, { [Op.like]: `%${value}%` }) 83 | } 84 | } else { 85 | queryHelper.setQuery(curId, curId.endsWith('Id') ? value : { [Op.like]: `%${value}%` }) 86 | } 87 | }) 88 | 89 | return sequelizeQuery 90 | } 91 | 92 | /** 93 | * Creates a query builder for sorting data 94 | * @returns Configured SqlizeQuery instance for sorting 95 | */ 96 | function getSortedQuery(): SqlizeQuery { 97 | const sequelizeQuery = new SqlizeQuery() 98 | sequelizeQuery.addValueParser(parserString) 99 | 100 | sequelizeQuery.addQueryBuilder((value, queryHelper) => { 101 | if (value?.sort) { 102 | queryHelper.setQuery(value.sort, value.order) 103 | } 104 | }) 105 | 106 | sequelizeQuery.addTransformBuild((buildValue, transformHelper) => { 107 | transformHelper.setValue( 108 | Object.entries(buildValue).map(([id, value]) => [...id.split('.'), value]) 109 | ) 110 | }) 111 | 112 | return sequelizeQuery 113 | } 114 | 115 | /** 116 | * Creates a query builder for pagination 117 | * @param limit - Optional maximum limit 118 | * @returns Configured SqlizeQuery instance for pagination 119 | */ 120 | function getPaginationQuery(limit = 1000): SqlizeQuery { 121 | const sequelizeQuery = new SqlizeQuery() 122 | const offsetId = 'page' 123 | const limitId = 'pageSize' 124 | const defaultOffset = 0 125 | const minLimit = 10 126 | 127 | sequelizeQuery.addValueParser((value) => { 128 | const pageSize = Math.min(Math.max(Number(value.pageSize) || minLimit, minLimit), limit) 129 | 130 | return [ 131 | { id: offsetId, value: Number(value.page) }, 132 | { id: limitId, value: pageSize }, 133 | ] 134 | }) 135 | 136 | sequelizeQuery.addQueryBuilder(({ id, value }, queryHelper) => { 137 | if (id === offsetId) { 138 | const offsetValue = queryHelper.getDataValueById(limitId) * (value - 1) 139 | queryHelper.setQuery('offset', offsetValue > 0 ? offsetValue : defaultOffset) 140 | } 141 | if (id === limitId) { 142 | queryHelper.setQuery('limit', value || minLimit) 143 | } 144 | }) 145 | 146 | return sequelizeQuery 147 | } 148 | 149 | /** 150 | * Builds an include query with filtering 151 | * @param params - Parameters for include filtering 152 | * @returns Configured include object 153 | */ 154 | function getIncludeFilteredQuery(params: SequelizeIncludeFilteredQuery): any { 155 | const { filteredValue, model, prefixName, options } = params 156 | const where = getFilteredQuery({ model: model as ModelStatic, prefixName }).build( 157 | filteredValue 158 | ) 159 | 160 | if (Object.keys(where).length === 0) { 161 | return { model, ...options } 162 | } 163 | 164 | return { 165 | model, 166 | where, 167 | required: true, 168 | ...options, 169 | } 170 | } 171 | 172 | /** 173 | * Filters includes to only return those with conditions 174 | * @param params - Parameters containing includes to filter 175 | * @returns Filtered includes array 176 | */ 177 | function filterIncludeHandledOnly(params: SequelizeFilterIncludeHandledOnly): any[] { 178 | const { include, filteredInclude = [] } = params 179 | 180 | if (!include) return filteredInclude 181 | 182 | for (const curModel of include) { 183 | let childIncludes = [] 184 | 185 | if (curModel.include) { 186 | childIncludes = filterIncludeHandledOnly({ 187 | include: curModel.include, 188 | }) 189 | } 190 | 191 | if (curModel.where || curModel.required || childIncludes.length > 0) { 192 | const clonedInclude = _.cloneDeep(curModel) 193 | _.unset(clonedInclude, 'include') 194 | 195 | if (childIncludes.length > 0) { 196 | clonedInclude.include = childIncludes 197 | } 198 | 199 | filteredInclude.push(clonedInclude) 200 | } 201 | } 202 | 203 | return filteredInclude 204 | } 205 | 206 | /** 207 | * Recursively injects required flag based on child includes 208 | * @param include - Array of includes to process 209 | * @returns Processed includes with required flags 210 | */ 211 | function injectRequireInclude(include: Includeable[]): Includeable[] { 212 | function processIncludes(dataInclude: Includeable[]): boolean { 213 | if (!dataInclude?.length) return false 214 | 215 | let hasRequired = false 216 | 217 | for (const item of dataInclude) { 218 | const optionInclude = item as IncludeOptions 219 | 220 | if (optionInclude.required) { 221 | hasRequired = true 222 | continue 223 | } 224 | 225 | if (optionInclude.include && processIncludes(optionInclude.include)) { 226 | optionInclude.required = true 227 | hasRequired = true 228 | } 229 | } 230 | 231 | return hasRequired 232 | } 233 | 234 | processIncludes(include) 235 | return include 236 | } 237 | 238 | /** 239 | * Transforms includes into queryable format with filtering 240 | * @param filteredValue - Values to filter by 241 | * @param includes - Includes to transform 242 | * @returns Queryable includes 243 | */ 244 | export function makeIncludeQueryable(filteredValue: any, includes: Includeable[]): any { 245 | return transfromIncludeToQueryable(includes, (value) => { 246 | const { model, key, ...restValue } = value 247 | return getIncludeFilteredQuery({ 248 | filteredValue, 249 | model, 250 | prefixName: value.key, 251 | options: { key, ...restValue } as IncludeOptions, 252 | }) 253 | }) 254 | } 255 | 256 | /** 257 | * Builds a query object for Sequelize 258 | * @param params - Query parameters 259 | * @param options - Sequelize connection options 260 | * @returns Query object with include, includeCount, where, order, offset, and limit 261 | */ 262 | function QueryBuilder( 263 | params: UseQuerySequelize, 264 | options?: SequelizeConnectionOptions 265 | ): DtoSequelizeQuery { 266 | const { model, reqQuery, includeRule, limit } = params 267 | const { onBeforeBuild } = params.options ?? {} 268 | 269 | const paginationQuery = getPaginationQuery(limit) 270 | const filteredQuery = getFilteredQuery({ model, options }) 271 | const sortedQuery = getSortedQuery() 272 | 273 | const includeCountRule = filterIncludeHandledOnly({ include: includeRule }) 274 | const include = injectRequireInclude(_.cloneDeep(includeRule) as Includeable[]) 275 | const includeCount = injectRequireInclude(_.cloneDeep(includeCountRule) as Includeable[]) 276 | 277 | if (onBeforeBuild) { 278 | onBeforeBuild({ 279 | filteredQuery, 280 | paginationQuery, 281 | sortedQuery, 282 | }) 283 | } 284 | 285 | const pagination = paginationQuery.build(reqQuery) 286 | const filter = filteredQuery.build(reqQuery.filtered) 287 | const sort = sortedQuery.build(reqQuery.sorted) 288 | 289 | return { 290 | include, 291 | includeCount, 292 | where: filter, 293 | order: sort, 294 | offset: pagination.offset, 295 | limit: pagination.limit, 296 | } 297 | } 298 | 299 | /** 300 | * Builds a query object for Sequelize 301 | * @param params - Query parameters 302 | * @returns Query object with include, includeCount, where, order, offset, and limit 303 | */ 304 | export function useQuery(params: UseQuerySequelize) { 305 | const dialect = env.SEQUELIZE_CONNECTION 306 | return QueryBuilder(params, { dialect }) 307 | } 308 | --------------------------------------------------------------------------------