init

parents
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist
build
.next
.nuxt
.cache
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
.gitattributes
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# Documentation
README.md
CHANGELOG.md
docs/
# Tests
coverage
.nyc_output
test-results/
playwright-report/
# Logs
logs
*.log
storage/logs/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Temporary folders
tmp/
temp/
# Storage (will be mounted as volume)
storage/uploads/
storage/temp/
# Environment Configuration Template
# Copy this file to .env and fill in your values
# Server Configuration
NODE_ENV=development
PORT=3001
FRONTEND_URL=http://localhost:3000,https://your-domain.com
BACKEND_URL=http://localhost:3001
# Cookie Configuration
COOKIE_DOMAIN=.your-domain.com
# Project Configuration
PROJECT_NAME=Backend Template
PROJECT_VERSION=1.0.0
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=backend_template
DB_USER=postgres
DB_PASSWORD=password
# JWT Configuration
JWT_SECRET=your_super_secure_jwt_secret_key_here_change_this_in_production
JWT_REFRESH_SECRET=your_super_secure_jwt_refresh_secret_key_here_change_this_in_production
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
TOKEN_ENCRYPTION_KEY=your_very_strong_32_character_encryption_key_for_tokens
# Email Configuration (Optional)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=your_email@gmail.com
EMAIL_PASS=your_app_password
EMAIL_FROM=noreply@yourapp.com
# File Upload Configuration
UPLOAD_PATH=./storage/uploads
MAX_FILE_SIZE=10485760
ALLOWED_FILE_TYPES=jpg,jpeg,png,pdf,doc,docx
# Logging Configuration
LOG_LEVEL=info
LOG_URL=./storage/logs
LOG_MODE=file
LOG_TIMEOUT=5000
module.exports = {
parser: "@typescript-eslint/parser",
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
plugins: ["@typescript-eslint"],
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
project: "./tsconfig.json",
},
env: {
node: true,
es2022: true,
},
rules: {
// TypeScript specific rules
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
// General rules
"no-console": "off", // Allow console.log in backend
"no-debugger": "error",
"prefer-const": "error",
"no-var": "error",
},
ignorePatterns: ["dist/", "node_modules/", "storage/", "**/*.js"],
};
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
src/config/production.json
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# user generated
storage/
src/templates/swagger/swagger-output.json
\ No newline at end of file
pnpm exec commitlint --config ./config/commitlint.config.js --edit "$1"
\ No newline at end of file
pnpm run lint-staged
\ No newline at end of file
{
"arrowParens": "always",
"semi": true,
"trailingComma": "all",
"tabWidth": 2,
"endOfLine": "auto",
"useTabs": true,
"printWidth": 120,
"jsxSingleQuote": true
}
FROM node:20-alpine3.19 AS base
WORKDIR /usr/src/app
RUN apk add --no-cache dumb-init curl git
RUN npm install -g pnpm
COPY ["package.json", "pnpm-lock.yaml", "./"]
RUN pnpm install --frozen-lockfile
COPY src/templates ./src/templates
FROM base AS build
COPY [".", "."]
RUN pnpm install
RUN npm run build
COPY src/templates ./dist/templates
FROM base AS production-deps
RUN pnpm install --frozen-lockfile --prod
FROM base AS development
ENV NODE_ENV=development
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["npm", "run", "dev"]
FROM node:20-alpine3.19 AS production
RUN apk add --no-cache dumb-init curl
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
WORKDIR /usr/src/app
ENV TZ="Asia/Bangkok"
ENV NODE_ENV=production
COPY ["package.json", "pnpm-lock.yaml", "./"]
RUN npm install -g pnpm
RUN pnpm install --prod --frozen-lockfile
COPY --from=build /usr/src/app/dist ./dist
COPY --from=build /usr/src/app/lib ./lib
COPY --from=build /usr/src/app/src/templates ./src/templates
RUN mkdir -p storage/swagger storage/logs storage/uploads && chown -R nextjs:nodejs storage
USER nextjs
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]
\ No newline at end of file
This diff is collapsed.
module.exports = {
extends: ["@commitlint/config-conventional"],
ignores: [(commit) => commit.includes("[tag]")],
rules: {
// Allow very long commit headers (in characters) to accommodate long summaries
"header-max-length": [2, "always", 2000],
// Allow long lines in the body/footer so a ~200-word message is accepted
"body-max-line-length": [2, "always", 2000],
"footer-max-line-length": [2, "always", 2000],
},
};
module.exports = {
"src/**/*.ts": (filenames) => ["pnpm run lint:fix", `prettier --write ${filenames.join(" ")}`],
};
version: "3.8"
services:
app-dev:
profiles: ["dev"]
build:
context: .
dockerfile: Dockerfile
target: development
container_name: case-smeq-dev
restart: unless-stopped
ports:
- "${PORT:-3001}:3001"
environment:
- NODE_ENV=development
- CHOKIDAR_USEPOLLING=true
env_file:
- .env
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
command: npm run dev
app-prod:
profiles: ["prod"]
build:
context: .
dockerfile: Dockerfile
target: production
container_name: case-smeq-prod
restart: unless-stopped
ports:
- "${PORT:-3001}:3001"
environment:
- NODE_ENV=staging
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
command: node dist/index.js
# API Generator Guide
## Overview
The API generator provides automated generation of RESTful API endpoints for your models. It supports two types of API generation:
1. **CRUD APIs**: Standard RESTful operations (Create, Read, Update, Delete) for individual records
2. **Bulk APIs**: Operations for handling multiple records at once
## Generated Endpoints
### CRUD APIs (`pnpm gen-api`)
Generates the following endpoints:
- `GET /{apiPath}` - Get all records (with pagination, filtering, sorting)
- `POST /{apiPath}` - Create a single record
- `GET /{apiPath}/{id}` - Get a record by ID
- `PUT /{apiPath}/{id}` - Update a record by ID
- `DELETE /{apiPath}/{id}` - Delete a record by ID
### Bulk APIs (`pnpm gen-api-bulk`)
Generates the following endpoints:
- `POST /{apiPath}` - Create multiple records (bulk create)
- `PUT /{apiPath}` - Update multiple records (bulk update)
- `DELETE /{apiPath}` - Delete multiple records (bulk delete)
## Usage
### Generate CRUD APIs
```bash
pnpm gen-api <model> [domain] [apiPath] [swaggerTag]
```
**Parameters:**
- `model` (required): Model name (must exist in your models)
- `domain` (optional): Domain folder (e.g., user-management)
- `apiPath` (optional): API path (defaults to model name)
- `swaggerTag` (optional): Swagger tag (defaults to capitalized model name)
**Examples:**
```bash
# Basic usage
pnpm gen-api user
# With domain
pnpm gen-api user user-management
# Full parameters
pnpm gen-api user user-management users UserManagement
```
### Generate Bulk APIs
```bash
pnpm gen-api-bulk <model> [domain] [apiPath] [swaggerTag]
```
**Parameters:**
- `model` (required): Model name (must exist in your models)
- `domain` (optional): Domain folder (e.g., user-management)
- `apiPath` (optional): API path (defaults to model name)
- `swaggerTag` (optional): Swagger tag (defaults to capitalized model name)
**Examples:**
```bash
# Basic usage
pnpm gen-api-bulk user
# With domain
pnpm gen-api-bulk user user-management
# Full parameters
pnpm gen-api-bulk user user-management users UserManagement
```
## Response Format
All generated endpoints use the standardized response format:
### Success Response
```json
{
"message": null,
"message_en": null,
"responseData": {
"id": "123",
"name": "Example"
},
"status": "success",
"timeStamp": "2025-12-08 10:00:00",
"violations": null
}
```
### Error Response
```json
{
"message": "Dữ liệu đầu vào không hợp lệ",
"message_en": "Invalid input data",
"responseData": null,
"status": "error",
"timeStamp": "2025-12-08 10:00:00",
"violations": {
"type": "VALIDATION_ERROR",
"code": 400,
"additionalData": {
"errors": [...]
}
}
}
```
## File Structure
After generation, the following files are created:
```
src/controllers/api/v1.0/{domain}/{apiPath}/
├── index.ts # Main endpoints (GET all + POST for CRUD, bulk operations for bulk)
└── {id}.ts # ID-based endpoints (only for CRUD)
src/providers/{domain}/
└── {ModelName}Provider.ts # Data access layer
```
## Authentication
- GET endpoints: Require authentication (Bearer token)
- POST/PUT/DELETE endpoints: Require authentication + authorization
## Middleware
- `queryModifier`: Handles pagination, filtering, sorting for GET requests
- `verify`: JWT authentication middleware
- `validator`: Input validation (if configured)
## Swagger Documentation
All endpoints are automatically documented with OpenAPI/Swagger specifications including:
- Request/response schemas
- Authentication requirements
- Parameter definitions
- Error responses
## Best Practices
1. **Use CRUD for single operations**: When you need to manipulate individual records
2. **Use Bulk for batch operations**: When importing data, updating multiple records, or bulk deletions
3. **Domain organization**: Use domains to group related APIs (e.g., `user-management`, `product-catalog`)
4. **Consistent naming**: Keep API paths consistent with model names
5. **Test generated code**: Always test the generated endpoints before deploying
## Customization
The templates are located in `lib/generator/api/templates/`:
- `controllerAllPath.mustache` - All records endpoints (GET all + POST for CRUD, bulk operations for bulk)
- `controllerIdPath.mustache` - ID-based CRUD operations
- `provider.mustache` - Data provider template
Modify these templates to customize the generated code according to your needs.
# API Response Guide
## Overview
API Response is a unified response handling system for backend-template, inspired by fosco-erp-backend. This system provides:
- Consistent response formatting for both success and error cases
- Multi-language support (Vietnamese and English)
- Automatic response formatting
- Easy extensibility
- Support for Joi validation, database errors, and custom errors
## GenericError Structure
```typescript
export class GenericError {
message: Record<Language, string>; // Multi-language error messages
type: string; // Error type (e.g., "VALIDATION_ERROR", "BAD_REQUEST")
code: number; // HTTP status code
additionalData?: unknown; // Additional data (optional)
}
```
## Usage
### 1. Import GenericError
```typescript
import { GenericError } from "#interfaces/error/generic";
```
### 2. Throw GenericError in Controller
```typescript
// Example validation error
if (!isValid) {
throw new GenericError({ vi: "Giá trị không hợp lệ", en: "Invalid value" }, "BAD_REQUEST", 400);
}
// Example with additional data
if (errors.length > 0) {
throw new GenericError({ vi: "Dữ liệu đầu vào không hợp lệ", en: "Invalid input data" }, "VALIDATION_ERROR", 400, {
errors: errors.array(),
});
}
```
### 3. Use res.error() in catch block
```typescript
try {
// Business logic
if (someCondition) {
throw new GenericError(
{ vi: "Điều kiện không thỏa mãn", en: "Condition not satisfied" },
"BUSINESS_LOGIC_ERROR",
422,
);
}
} catch (error) {
// Automatically handle GenericError and format response
res.error(error);
}
```
## Response Format
### Success Response
When using `res.sendOk()`, the response will have a standard format:
```json
{
"message": "User created successfully",
"message_en": "User created successfully",
"responseData": {
"id": "123",
"email": "user@example.com"
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
### List Response (with pagination)
For list endpoints with pagination:
```json
{
"message": null,
"message_en": null,
"responseData": {
"rows": [
{ "id": "123", "email": "user1@example.com" },
{ "id": "124", "email": "user2@example.com" }
],
"count": 50,
"page": 1,
"pageSize": 10
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
### Single Item Response
For single item endpoints:
```json
{
"message": null,
"message_en": null,
"responseData": {
"id": "123",
"email": "user@example.com",
"first_name": "John"
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
## Error Processors
The system includes multiple error handlers for different error types:
- **JoiValidateErrorHandler**: Handles Joi validation errors
- **DatabaseErrorHandler**: Handles Sequelize database errors (ValidationError, UniqueConstraintError, ForeignKeyConstraintError)
- **GenericErrorHandler**: Fallback handler for GenericError and unknown errors
## Common Error Types
| Type | Code | Description |
| ----------------------- | ---- | --------------------- |
| `BAD_REQUEST` | 400 | Invalid input data |
| `VALIDATION_ERROR` | 400 | Validation error |
| `UNAUTHORIZED` | 401 | Not authenticated |
| `FORBIDDEN` | 403 | Access denied |
| `NOT_FOUND` | 404 | Resource not found |
| `CONFLICT` | 409 | Data conflict |
| `BUSINESS_LOGIC_ERROR` | 422 | Business logic error |
| `INTERNAL_SERVER_ERROR` | 500 | Internal server error |
## Real Examples
### Get Users (List with pagination)
```typescript
export const getUsers = [
...getUsersValidation,
async (req: Request, res: Response) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
throw new GenericError(
{ vi: "Dữ liệu đầu vào không hợp lệ", en: "Invalid input data" },
"VALIDATION_ERROR",
400,
{ errors: errors.array() },
);
}
const result = await UserProvider.findAll({
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 10,
// ... other filters
});
return res.sendOk({
data: {
rows: result.users,
count: result.total,
page: result.page,
pageSize: result.limit,
},
});
} catch (error) {
res.error(error);
}
},
];
```
### Get Single User
```typescript
export const getUser = [
param("id").isUUID(),
async (req: Request, res: Response) => {
try {
const user = await UserProvider.findById(req.params.id);
if (!user) {
throw new GenericError({ vi: "Người dùng không tồn tại", en: "User not found" }, "NOT_FOUND", 404);
}
return res.sendOk({ data: user });
} catch (error) {
res.error(error);
}
},
];
```
### Create User
```typescript
export const createUser = [
...createUserValidation,
async (req: Request, res: Response) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
throw new GenericError(
{ vi: "Dữ liệu đầu vào không hợp lệ", en: "Invalid input data" },
"VALIDATION_ERROR",
400,
{ errors: errors.array() },
);
}
const user = await UserProvider.create(userData);
return res.sendOk({
data: user,
message: "User created successfully",
statusCode: 201,
});
} catch (error) {
res.error(error);
}
},
];
```
### Validation with GenericError
```typescript
// Instead of res.status(400).json()
if (value < 0 || value > 100) {
throw new GenericError(
{ vi: "Giá trị phải từ 0 đến 100", en: "Value must be between 0 and 100" },
"VALIDATION_ERROR",
400,
);
}
```
## Benefits
1. **Consistency**: All errors have the same format
2. **Multi-language**: Automatic vi/en support based on `accept-language` header
3. **Easy debugging**: `additionalData` can contain detailed debug information
4. **Automatic handling**: `res.error()` automatically formats response
5. **Extensible**: Easy to add new error handler types
6. **Database error handling**: Automatic handling of common database errors
## Best Practices
1. **Use clear messages**: Describe exactly what error occurred
2. **Choose appropriate codes**: Use standard HTTP status codes
3. **Add additionalData when needed**: Helps with debugging and client-side error handling
4. **Throw immediately when error detected**: Don't continue processing when an error occurs
5. **Use res.error() in catch**: To ensure unified handling
## Migration from Old Approach
### Before (old way):
```typescript
if (!errors.isEmpty()) {
return res.sendErrorStatus({
status: 400,
message: "Error message",
err: new MeUError(...)
});
}
```
### After (GenericError):
```typescript
if (!errors.isEmpty()) {
throw new GenericError({ vi: "Dữ liệu không hợp lệ", en: "Invalid data" }, "VALIDATION_ERROR", 400, {
errors: errors.array(),
});
}
```
And in catch block:
```typescript
} catch (error) {
res.error(error); // Automatic handling
}
```
## Related Files
- `src/interfaces/error/generic.ts` - GenericError class definition
- `src/interfaces/error/violations/IViolations.ts` - Error processor
- `src/interfaces/error/violations/ViolationImpl.ts` - GenericError handler
- `src/interfaces/error/handler/joi-error-handler.ts` - Joi validation handler
- `src/interfaces/error/handler/database-error-handler.ts` - Database error handler
- `src/middlewares/response.ts` - res.error() implementation
- `src/middlewares/validator.ts` - Updated validator middleware
- `src/interfaces/IApi.ts` - Res interface with error method
This diff is collapsed.
# Coding Convention
This document outlines the coding standards and conventions to be followed in the backend-template-typescript-nodejs project. Adhering to these guidelines ensures code consistency, readability, and maintainability.
## Table of Contents
1. [General Principles](#general-principles)
2. [TypeScript Guidelines](#typescript-guidelines)
3. [File and Folder Structure](#file-and-folder-structure)
4. [Naming Conventions](#naming-conventions)
5. [Code Style](#code-style)
6. [Imports and Exports](#imports-and-exports)
7. [Error Handling](#error-handling)
8. [Testing](#testing)
9. [Documentation](#documentation)
10. [Database Conventions](#database-conventions)
11. [API Generation Guidelines](#api-generation-guidelines)
12. [API Response Guidelines](#api-response-guidelines)
13. [Authentication Guidelines](#authentication-guidelines)
14. [Query Modifier Guidelines](#query-modifier-guidelines)
15. [Git and GitLab Guidelines](#git-and-gitlab-guidelines)
## General Principles
- Write clean, readable, and maintainable code.
- Follow the DRY (Don't Repeat Yourself) principle.
- Use meaningful names for variables, functions, and classes.
- Keep functions small and focused on a single responsibility.
- Comment code when necessary, but prefer self-documenting code.
## TypeScript Guidelines
- Use TypeScript for all new code.
- Enable strict mode in `tsconfig.json`.
- Use interfaces for object types.
- Avoid `any` type; use specific types instead.
- Use union types and generics where appropriate.
## File and Folder Structure
- Follow the existing project structure:
- `src/` for source code
- `lib/` for build outputs and utilities
- `config/` for configuration files
- `guidelines/` for documentation
- Use kebab-case for file names (e.g., `user-service.ts`).
- Group related files in folders (e.g., `controllers/`, `services/`, `models/`).
## Naming Conventions
- **Variables and Functions**: camelCase (e.g., `userService`, `getUserById`)
- **Classes and Interfaces**: PascalCase (e.g., `UserService`, `IUser`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `MAX_RETRY_COUNT`)
- **Files**: kebab-case (e.g., `user-controller.ts`)
- **Folders**: kebab-case (e.g., `user-management`)
## Code Style
- Use ESLint and Prettier for code formatting and linting.
- Configure them in the project root.
- Maximum line length: 100 characters.
- Use 2 spaces for indentation.
- Use single quotes for strings.
- Add semicolons at the end of statements.
## Imports and Exports
- Use ES6 import/export syntax.
- Group imports: Node.js modules, third-party libraries, local modules.
- Use absolute paths for imports within the project.
- Export only what needs to be public.
## Error Handling
- Use try-catch blocks for synchronous code.
- Use promises or async/await for asynchronous operations.
- Create custom error classes extending the built-in Error class.
- Log errors appropriately using the logging service.
## Testing
- Write unit tests for all functions and classes.
- Use a testing framework like Jest.
- Aim for high code coverage.
- Place test files next to the code they test (e.g., `user-service.test.ts`).
## Tools and Configuration
- **Linting**: ESLint
- **Formatting**: Prettier
- **Testing**: Jest
- **Build**: TypeScript compiler
- **Package Manager**: pnpm
## Database Conventions
- Use PostgreSQL as the primary database.
- Follow naming conventions: snake_case for table and column names (e.g., `user_sessions`).
- Use UUID for primary keys where possible.
- Include timestamps: `created_at`, `updated_at`.
- Use enums for status fields (e.g., user_status).
- Implement foreign key constraints.
- Use indexes on frequently queried columns.
- Store sensitive data encrypted (e.g., passwords with bcrypt).
- Use JSONB for flexible data structures.
- Follow the schema structure in `sql/` folder for auth and other modules.
## API Generation Guidelines
- Use the API generator for automated CRUD and bulk endpoints.
- Commands: `pnpm gen-api <model>` for CRUD, `pnpm gen-api-bulk <model>` for bulk operations.
- Generated endpoints include GET all, POST create, GET by ID, PUT update, DELETE by ID.
- Specify domain, apiPath, and swaggerTag as needed.
- Ensure models exist before generating APIs.
- Customize generated code as necessary after generation.
## API Response Guidelines
- Use GenericError for consistent error handling.
- Support multi-language messages (Vietnamese and English).
- Throw GenericError with message object, type, code, and optional additionalData.
- Use res.error() in catch blocks for automatic formatting.
- Follow unified response format for success and error cases.
- Integrate with Joi validation and database error handlers.
## Authentication Guidelines
- Implement JWT-based authentication with access and refresh tokens.
- Use role-based access control (RBAC) with roles: user, admin, system_admin.
- Admin-only user creation; no self-registration.
- Support 2FA (TOTP, SMS, Email).
- Track user sessions with device info and token versioning.
- Implement password history and security monitoring.
- Use bcrypt for password hashing.
- Log authentication events for audit purposes.
- Follow the database schema in `sql/auth-schema.sql`.
## Query Modifier Guidelines
- Use API Query Modifier to transform array and boolean query parameters.
- Parameters ending with `[]` become arrays; starting with `is_` become booleans.
- Implement Query Modifier for pagination, sorting, and filtering.
- Support query parameters like `page`, `limit`, `sort`, `filter`.
- Use middleware in server.ts for automatic transformation.
## Git and GitLab Guidelines
- Use the company's GitLab for all projects; request account from mentor if needed.
- All names (variables, functions, branches, commits) must be in English.
- Branch naming: Start with `feat/` for new features (e.g., `feat/user-auth`), `fix/` for bug fixes (e.g., `fix/login-error`).
- Commit messages: Follow Conventional Commits format: `<type>(<scope>): <subject>`.
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
- Subject: <= 50 characters, imperative mood, no period at end.
- Optional body: Explain why, max ~72 characters per line.
- Optional footer: Reference issues/tickets.
- Workflow: Clone repo, checkout develop, create feature branch, commit changes, push, create Merge Request (MR) to develop, notify maintainer.
- After merge: Checkout develop, pull, create new branch for next task.
Follow these conventions to maintain code quality across the project. If you have suggestions for improvements, please discuss with the team.
Follow these conventions to maintain code quality across the project. If you have suggestions for improvements, please discuss with the team.
- **Testing**: Jest
- **Build**: TypeScript compiler
- **Package Manager**: pnpm
Follow these conventions to maintain code quality across the project. If you have suggestions for improvements, please discuss with the team.
# Query Modifier Guide
## Overview
Query Modifier is a middleware system that automatically transforms incoming query parameters to make them easier to work with in your controllers. This system includes two main components:
1. **API Query Modifier**: Transforms array and boolean query parameters
2. **Query Modifier**: Handles pagination, sorting, and filtering for database queries
## API Query Modifier
### Purpose
The API Query Modifier automatically transforms query parameters based on naming conventions:
- Parameters ending with `[]` are converted to arrays
- Parameters starting with `is_` are converted to booleans
### Usage Examples
#### Array Parameters
```javascript
// Input: ?status[]=active&status[]=inactive&status[]=pending
// Output: { status: ['active', 'inactive', 'pending'] }
// Input: ?ids[]=1&ids[]=2&ids[]=null
// Output: { ids: [1, 2, null] }
```
#### Boolean Parameters
```javascript
// Input: ?is_active=true&is_verified=1&is_deleted=false&is_locked=0
// Output: { is_active: true, is_verified: true, is_deleted: false, is_locked: false }
```
### Implementation
```typescript
import { apiQueryModifier } from "./middlewares/mod/api-query-modifier";
// In server.ts
app.use(apiQueryModifier());
```
## Query Modifier
### Purpose
The Query Modifier processes complex query parameters for database operations, including:
- Pagination (page, pageSize)
- Sorting (sortField, sortOrder)
- Filtering (filters with Sequelize conditions)
### Query Parameters
#### Pagination
```javascript
// ?page=2&pageSize=20
{
page: 2,
pageSize: 20
}
```
#### Sorting
```javascript
// ?sortField=created_at&sortOrder=DESC
{
sortField: 'created_at',
sortOrder: 'DESC'
}
```
#### Filtering
```javascript
// ?filters=field1:value1,field2:value2,field3|>10
{
filters: {
[Op.and]: [
{ field1: { [Op.eq]: 'value1' } },
{ field2: { [Op.eq]: 'value2' } },
{ field3: { [Op.gt]: 10 } }
]
}
}
```
### Filter Syntax
#### Basic Filters
- `field:value` - Equal to
- `field|>value` - Greater than
- `field|<value` - Less than
- `field|>=value` - Greater than or equal
- `field|<=value` - Less than or equal
- `field|!=value` - Not equal
- `field|%value%` - Like (contains)
- `field|%value` - Like (ends with)
- `field|value%` - Like (starts with)
#### OR Conditions
- `field1:value1|field2:value2` - OR condition
### Usage in Controllers
```typescript
import queryModifier from "./middlewares/query-modifier";
export const getUsers = [
queryModifier, // Apply query modifier
async (req: Req, res: Response) => {
const { payload } = req; // Contains processed query parameters
const result = await UserProvider.findAll({
page: payload.page,
pageSize: payload.pageSize,
sortField: payload.sortField,
sortOrder: payload.sortOrder,
filters: payload.filters,
});
return res.sendOk({
data: {
rows: result.users,
count: result.total,
page: result.page,
pageSize: result.limit,
},
});
},
];
```
## Middleware Order
In `server.ts`, middlewares should be applied in this order:
```typescript
app.use(responseTemplate, apiQueryModifier(), queryModifier);
```
1. **responseTemplate**: Adds response methods (sendOk, error, etc.)
2. **apiQueryModifier**: Transforms query parameter types
3. **queryModifier**: Processes pagination/filtering parameters
## Benefits
1. **Automatic Type Conversion**: No manual parsing of arrays and booleans
2. **Consistent Filtering**: Standardized filter syntax across all endpoints
3. **Pagination Support**: Built-in pagination handling
4. **Database Agnostic**: Works with any database through Sequelize
5. **Client Friendly**: Simple query parameters for complex operations
## Examples
### Complete Controller Example
```typescript
import { Request, Response } from "express";
import { Req } from "#interfaces/IApi";
import queryModifier from "#middlewares/query-modifier";
export const getUsers = [
queryModifier,
async (req: Req, res: Response) => {
try {
const { payload } = req;
// payload contains:
// - page, pageSize (numbers)
// - sortField, sortOrder (strings)
// - filters (Sequelize where conditions)
// - dateField (array for date filtering)
const users = await User.findAll({
where: payload.filters,
limit: payload.pageSize,
offset: (payload.page - 1) * payload.pageSize,
order: payload.sortField ? [[payload.sortField, payload.sortOrder]] : undefined,
});
const total = await User.count({ where: payload.filters });
return res.sendOk({
data: {
rows: users,
count: total,
page: payload.page,
pageSize: payload.pageSize,
},
});
} catch (error) {
res.error(error);
}
},
];
```
### Client Usage Examples
```javascript
// Get users with pagination
GET /api/users?page=1&pageSize=10
// Get users with sorting
GET /api/users?sortField=name&sortOrder=ASC
// Get users with filtering
GET /api/users?filters=status:active,role:admin
// Get users with array parameters
GET /api/users?roles[]=admin&roles[]=user&is_active=true
// Complex filtering
GET /api/users?filters=created_at|>2024-01-01,status:active|status:pending
```
## Configuration
### Default Values
- `page`: 1
- `pageSize`: 10
- `sortOrder`: null (no sorting)
### Limits
- `pageSize` max: 100 (configurable in middleware)
## Error Handling
The middleware automatically handles:
- Invalid page/pageSize values (defaults to valid values)
- Malformed filter strings (skips invalid filters)
- Type conversion errors (graceful fallbacks)
## Related Files
- `src/middlewares/mod/api-query-modifier.ts` - API query parameter transformation
- `src/middlewares/query-modifier.ts` - Database query processing
- `src/services/database/build-condition.ts` - Filter condition building
- `src/interfaces/IApi.ts` - Type definitions
# Joi Validation Guide
## Overview
This guide explains how to use Joi validation in the backend API for consistent request validation across all endpoints.
## Structure
Validators are organized by feature in the `src/middlewares/validators/` directory:
- `joi.ts` - Core validation function and common schemas
- `auth.ts` - Authentication related validations
- `user.ts` - User management validations
- `{model}.ts` - Model-specific validations (generated automatically)
## Core Validation Function
The `validateJoi` function is the main validation middleware that:
- Validates request body, params, and query based on provided schema
- Returns appropriate error responses for validation failures
- Supports custom error messages in Vietnamese
## Creating Validators
For each model/feature, create a validator file with the following pattern:
```typescript
import Joi from "joi";
import { validateJoi } from "../joi";
// Model validation schemas
export const modelSchemas = {
create: Joi.object({
body: Joi.object({
// TODO: Add validation rules for model creation
// Example:
// name: Joi.string().min(1).max(100).required(),
// description: Joi.string().optional(),
}),
params: Joi.object(),
query: Joi.object(),
}),
update: Joi.object({
params: Joi.object({
id: Joi.string().required(),
}),
body: Joi.object({
// TODO: Add validation rules for model update
// All fields should be optional for partial updates
}),
query: Joi.object(),
}),
};
// Pre-built validators
export const validateModelCreate = validateJoi(modelSchemas.create);
export const validateModelUpdate = validateJoi(modelSchemas.update);
```
**Note**: The API generator (`pnpm gen-api <model>`) will automatically create this template structure with TODO comments for you to customize.
## Using Validators in Controllers
Apply validators to your controller endpoints:
```typescript
import { validateModelCreate, validateModelUpdate } from "#middlewares/validators/model";
export default (_express: Application) => ({
post: {
middleware: [verify, queryModifier, validateModelCreate],
handler: async (req: Req, res: Res) => {
// Handler logic
},
},
put: {
middleware: [verify, queryModifier, validateId, validateModelUpdate],
handler: async (req: Req, res: Res) => {
// Handler logic
},
},
});
```
## Common Validation Patterns
### Required Fields
```typescript
fieldName: Joi.string().required().messages({
"any.required": "Field name là bắt buộc",
});
```
### Optional Fields
```typescript
fieldName: Joi.string().optional();
```
### String Validation
```typescript
fieldName: Joi.string().min(1).max(100).trim().required();
```
### Email Validation
```typescript
email: Joi.string().email().required().messages({
"string.email": "Email không hợp lệ",
});
```
### Number Validation
```typescript
age: Joi.number().integer().min(0).max(150).optional();
```
### Date Validation
```typescript
birthDate: Joi.date().iso().optional();
```
### Array Validation
```typescript
tags: Joi.array().items(Joi.string()).optional();
```
### Enum Validation
```typescript
status: Joi.string().valid("active", "inactive", "pending").required();
```
## Error Messages
- Use Vietnamese for user-facing error messages
- Keep technical error details in English for logging
- Use descriptive field names in error messages
## Auto-generated Validators
When using the API generator (`pnpm gen-api <model>`), validators are automatically created and applied to:
- **POST endpoints**: `validateModelCreate`
- **PUT endpoints**: `validateId` + `validateModelUpdate`
- **GET /{id} endpoints**: `validateId`
- **GET / endpoints**: No Joi validation (handled by `queryModifier` for standard parameters)
The generator will:
1. Create validator file in `src/middlewares/validators/{model}.ts` (if not exists)
2. Generate controllers with proper validator imports and middleware
3. Provide TODO comments in validator schemas for you to customize validation rules
**Note**: For custom query parameter validation on GET endpoints, manually add validation schemas and apply the validator to the controller middleware.
## Testing Validation
Test your validators with various inputs:
- Valid data
- Missing required fields
- Invalid data types
- Boundary values
- Malformed requests
## Best Practices
1. Always validate input data
2. Use appropriate validation rules for each field type
3. Provide clear, localized error messages
4. Keep validation schemas organized by feature
5. Test validation thoroughly
6. Update validators when API contracts change
### Query Parameters
- **Standard parameters** (handled by `queryModifier` middleware):
- `page`: Integer, minimum 1
- `pageSize`: Integer, minimum 1, maximum 100
- `filters`: String (Sequelize filter conditions)
- `sortField`: String (field to sort by)
- `sortOrder`: String, values: 'asc', 'desc'
- **Custom parameters** (validate in Joi):
- `search`: String, maximum 255 characters, trimmed
- `status`: Valid enum values matching your model
### Integration with Query Modifier
When using both Joi validation and `queryModifier` middleware:
1. Joi validates parameter types and custom business rules (when applied)
2. `queryModifier` processes standard pagination/filtering parameters
3. Controllers receive processed data in `req.payload`
**Note**: Auto-generated GET endpoints don't include Joi validation by default. Add custom validators manually if needed for specific query parameters.
### Custom Query Parameters
When adding custom query parameters beyond the standard ones, create a custom validator:
**Recommended to validate:**
- Enum values (status, type, category, etc.)
- Numeric ranges with constraints
- Date formats
- Boolean flags
**Optional to validate:**
- Basic strings (search, name, etc.) - if only length/format validation is needed
- Simple IDs - if already validated elsewhere
**Example custom validator:**
```typescript
// In your validator file
export const validateModelGetAll = validateJoi({
params: Joi.object(),
body: Joi.object(),
query: Joi.object({
// Recommended: Validate enum values
status: Joi.string().valid('active', 'inactive', 'suspended').optional(),
category: Joi.string().valid('electronics', 'books', 'clothing').optional(),
// Optional: Basic string validation
search: Joi.string().max(255).optional().trim(),
// Recommended: Numeric constraints
minPrice: Joi.number().min(0).optional(),
maxPrice: Joi.number().min(0).optional(),
}),
});
// In your controller
import { validateModelGetAll } from "#middlewares/validators/model";
get: {
middleware: [verify, queryModifier, validateModelGetAll], // Add this if custom validation needed
handler: async (req: Req, res: Res) => {
// Handler logic
},
},
```
// Builder reqs
const { build } = require("esbuild");
const { sys, readConfigFile, findConfigFile, parseJsonConfigFileContent } = require("typescript");
// Prebuild reqs
const { resolve } = require("path");
const { mkdir, cp } = require("fs/promises");
const { existsSync, mkdirSync, writeFileSync } = require("fs");
const { pathToFileURL } = require("url");
const { rimraf } = require("rimraf");
const cwd = process.cwd();
const isDevEnv = process.env.NODE_ENV?.toLowerCase() === "development";
async function generateSwagger(storagePath) {
const swaggerJSDoc = (await import("swagger-jsdoc")).default;
const getSwaggerConfig = require("../generator/openapi");
const config = await getSwaggerConfig();
config.apis = ["src/controllers/**/*.ts"]; // Scan source TypeScript files
const spec = swaggerJSDoc(config);
const swaggerServePath = `${storagePath}/swagger/`;
mkdirSync(swaggerServePath, { recursive: true });
writeFileSync(`${swaggerServePath}/swagger-output.json`, JSON.stringify(spec, null, 2));
}
console.time("Built time");
(async function main() {
try {
const { esbuildOptions } = getEsbuildMetadata({ esbuild: { minify: false } });
const buildPath = resolve(__dirname, "../../dist");
const templatePath = resolve(buildPath, "templates");
const buildConfigPath = resolve(buildPath, "config");
// Pre-build: Clean and prepare directories
if (existsSync(buildPath)) {
await rimraf(buildPath);
}
await mkdir(buildPath, { recursive: true });
// Build with esbuild
esbuildOptions.entryPoints = ["src/**/*.ts"];
await build({
bundle: false,
format: "cjs",
platform: "node",
legalComments: "inline",
...esbuildOptions,
});
// Transpile controllers
await build({
bundle: false,
format: "esm",
platform: "node",
entryPoints: ["src/controllers/**/*.ts"],
outdir: "dist",
target: "es2022",
legalComments: "inline",
});
// Copy templates
await cp(resolve(__dirname, "../../src/templates"), templatePath, { recursive: true });
// Copy necessary files
// await cp(resolve(__dirname, "../../src/config"), buildConfigPath, { recursive: true });
console.timeEnd("Built time");
process.exit(0);
} catch (err) {
console.error("Build failed:", err);
process.exit(1);
}
})();
function getTSConfig(_tsConfigFile = "tsconfig.json") {
const tsConfigFile = findConfigFile(cwd, sys.fileExists, _tsConfigFile);
if (!tsConfigFile) throw new Error(`tsconfig.json not found in the current directory! ${cwd}`);
const configFile = readConfigFile(tsConfigFile, sys.readFile);
const tsConfig = parseJsonConfigFileContent(configFile.config, sys, cwd);
return { tsConfig, tsConfigFile };
}
function esBuildSourceMapOptions(tsConfig) {
const { sourceMap, inlineSources, inlineSourceMap } = tsConfig.options;
// inlineSources requires either inlineSourceMap or sourceMap
if (inlineSources && !inlineSourceMap && !sourceMap) return false;
// Mutually exclusive in tsconfig
if (sourceMap && inlineSourceMap) return false;
if (inlineSourceMap) return "inline";
return sourceMap;
}
function getEsbuildMetadata(userConfig) {
const { tsConfig, tsConfigFile } = getTSConfig(userConfig.tsConfigFile);
const esbuildConfig = userConfig.esbuild || {};
const outdir = esbuildConfig.outdir || tsConfig.options.outDir || "dist";
const srcFiles = [...(esbuildConfig.entryPoints ?? []), ...tsConfig.fileNames];
const sourcemap = userConfig.esbuild?.sourcemap || esBuildSourceMapOptions(tsConfig);
const target = esbuildConfig?.target || tsConfig?.raw?.compilerOptions?.target || "esNext";
const esbuildOptions = {
...userConfig.esbuild,
outdir,
entryPoints: srcFiles,
sourcemap,
target: target.toLowerCase(),
tsconfig: tsConfigFile,
};
return { esbuildOptions };
}
const { resolve } = require("path");
const { writeFileSync, existsSync, readFileSync, mkdirSync } = require("fs");
const Mustache = require("mustache");
const initExport = require("../../../dist/services/database/sequelize/initExport");
const baseDir = resolve(__dirname, "../../../src");
const controllerPath = resolve(baseDir, "controllers/api/v1.0");
const providerPath = resolve(baseDir, "providers");
const validatorPath = resolve(baseDir, "middlewares/validators");
const execute = async () => {
try {
const args = process.argv.slice(2);
// Check for mode flag
let mode = "crud"; // default to crud
let argIndex = 0;
if (args[0] === "--bulk") {
mode = "bulk";
argIndex = 1;
} else if (args[0] === "--crud") {
mode = "crud";
argIndex = 1;
}
const remainingArgs = args.slice(argIndex);
if (remainingArgs.length < 1) {
console.log("Usage: node index.js [--crud|--bulk] <model> [domain] [apiPath] [swaggerTag]");
console.log("Examples:");
console.log(" node index.js user # Generate CRUD APIs");
console.log(" node index.js --bulk user # Generate bulk APIs");
console.log(" node index.js --crud user user-management users UserManagement");
console.log("Note: --dry-run mode has been removed. Generation now happens directly.");
return;
}
const model = remainingArgs[0]?.trim();
let domain = remainingArgs[1]?.trim() || "";
const apiPathLink = remainingArgs[2]?.trim() || model;
const swaggerTag = remainingArgs[3]?.trim() || model.replace(/^./, model.charAt(0).toUpperCase());
// Skip --dry-run as domain
if (domain === "--dry-run") {
domain = "";
}
if (!model) {
console.log("Model name cannot be empty.");
return;
}
const modelsObject = initExport.default || initExport;
const modelUpperCase = model.replace(/^./, model.charAt(0).toUpperCase());
if (!Object.keys(modelsObject).includes(modelUpperCase)) {
console.log("No model match your input");
return;
}
// Get model attributes for conditional template rendering
const modelInstance = modelsObject[modelUpperCase];
const modelAttributes = modelInstance?.getAttributes ? Object.keys(modelInstance.getAttributes()) : [];
const hasStatus = modelAttributes.includes("status");
const hasIsActive = modelAttributes.includes("isActive") || modelAttributes.includes("is_active");
const hasIsDeleted =
modelAttributes.includes("isDeleted") ||
modelAttributes.includes("is_deleted") ||
modelAttributes.includes("deleted_at");
const domainPath = domain ? `${domain}/` : "";
const providerBasePath = resolve(providerPath, domain);
// Prepare replacements
const replace = {
modelUpperCase,
modelLowerCase: model,
swaggerTag,
apiPathLink,
domainPath,
isBulk: mode === "bulk",
isCrud: mode === "crud",
hasStatus,
hasIsActive,
hasIsDeleted,
};
// Read templates
const providerFile = readFileSync(`${__dirname}/templates/provider.mustache`, "utf-8");
const controllerAllFile = readFileSync(`${__dirname}/templates/controllerAllPath.mustache`, "utf-8");
const controllerIdFile = readFileSync(`${__dirname}/templates/controllerIdPath.mustache`, "utf-8");
const validatorFile = readFileSync(`${__dirname}/templates/validators.mustache`, "utf-8");
const apiPath = resolve(controllerPath, apiPathLink);
const providerFilePath = resolve(providerBasePath, `${modelUpperCase}Provider.ts`);
// Generate provider if not exists
if (!existsSync(providerFilePath)) {
mkdirSync(providerBasePath, { recursive: true });
writeFileSync(providerFilePath, Mustache.render(providerFile, replace));
console.log(`✓ Generated provider: ${providerFilePath}`);
} else {
console.log(`Provider already exists: ${providerFilePath}`);
}
// Generate validator if not exists
const validatorFilePath = resolve(validatorPath, `${model}.ts`);
if (!existsSync(validatorFilePath)) {
mkdirSync(validatorPath, { recursive: true });
writeFileSync(validatorFilePath, Mustache.render(validatorFile, replace));
console.log(`✓ Generated validator: ${validatorFilePath}`);
} else {
console.log(`Validator already exists: ${validatorFilePath}`);
}
// Generate controllers
mkdirSync(apiPath, { recursive: true });
// Generate index.ts (all path operations)
writeFileSync(`${apiPath}/index.ts`, Mustache.render(controllerAllFile, replace));
console.log(`✓ Generated controller: ${apiPath}/index.ts`);
// Generate {id}.ts only for CRUD mode
if (mode === "crud") {
writeFileSync(`${apiPath}/{id}.ts`, Mustache.render(controllerIdFile, replace));
console.log(`✓ Generated controller: ${apiPath}/{id}.ts`);
}
console.log(`✓ API generation complete for ${modelUpperCase} (${mode} mode)`);
} catch (error) {
console.error("Error generating API:", error);
}
};
execute();
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { {{ modelUpperCase }}Provider } from "#providers/{{ modelUpperCase }}Provider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import verify from "#middlewares/auth";
import { validate{{ modelUpperCase }}Create } from "#middlewares/validators/{{ modelLowerCase }}";
export default (_express: Application) => {
const {{ modelLowerCase }}Provider = new {{ modelUpperCase }}Provider();
return <Resource>{
{{^isBulk}} /**
* @openapi
* /{{{ apiPathLink }}}:
* get:
* tags: [{{ swaggerTag }}]
* summary: Get all {{ modelLowerCase }}
* description: Retrieve a list of {{ modelLowerCase }} with pagination, filtering and sorting
* security:
* - BearerAuth: []
* parameters:
* - $ref: '#/components/parameters/filters'
* - $ref: '#/components/parameters/sortField'
* - $ref: '#/components/parameters/sortOrder'
* - $ref: '#/components/parameters/page'
* - $ref: '#/components/parameters/pageSize'
* responses:
* 200:
* description: Successfully retrieved {{ modelLowerCase }}
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/responseGetAllData"
*/
get: {
middleware: [verify, queryModifier],
handler: async (req: Req, res: Res) => {
try {
const data = await {{ modelLowerCase }}Provider.getAll(req.payload || {});
return res.sendOk({ data });
} catch (error) {
await {{ modelLowerCase }}Provider.logError(error as Error);
return res.error(error);
}
},
},
{{/isBulk}}
/**
* @openapi
* /{{{ apiPathLink }}}:
* post:
* tags: [{{ swaggerTag }}]
* summary: {{#isBulk}}Create multiple {{ modelLowerCase }}{{/isBulk}}{{^isBulk}}Create a {{ modelLowerCase }}{{/isBulk}}
* description: {{#isBulk}}Create multiple {{ modelLowerCase }} records{{/isBulk}}{{^isBulk}}Create a new {{ modelLowerCase }} record{{/isBulk}}
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* {{#isBulk}}type: array
* items:
* $ref: "#/components/schemas/{{ modelUpperCase }}Mutate"{{/isBulk}}{{^isBulk}}$ref: "#/components/schemas/{{ modelUpperCase }}Mutate"{{/isBulk}}
* responses:
* 200:
* description: {{#isBulk}}{{ modelUpperCase }} records created{{/isBulk}}{{^isBulk}}{{ modelUpperCase }} created{{/isBulk}} successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* {{#isBulk}}type: array
* items:
* $ref: "#/components/schemas/{{ modelUpperCase }}"{{/isBulk}}{{^isBulk}}$ref: "#/components/schemas/{{ modelUpperCase }}"{{/isBulk}}
*/
post: {
middleware: [verify, queryModifier, validate{{ modelUpperCase }}Create],
handler: async (req: Req, res: Res) => {
try {
{{#isBulk}} const data = await {{ modelLowerCase }}Provider.bulkCreate(req.body.map(b => ({ ...b, created_at: new Date(), created_by: req.user?.id || null })));
{{/isBulk}}{{^isBulk}} const data = await {{ modelLowerCase }}Provider.create({ ...req.body, created_at: new Date(), created_by: req.user?.id || null });
{{/isBulk}} return res.sendOk({ data, message: "{{ modelUpperCase }} created successfully" });
} catch (error) {
await {{ modelLowerCase }}Provider.logError(error as Error);
return res.error(error);
}
},
},
};
};
\ No newline at end of file
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { {{ modelUpperCase }}Provider } from "#providers/{{ modelUpperCase }}Provider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import verify from "#middlewares/auth";
import { validateId } from "#middlewares/validators";
import { validate{{ modelUpperCase }}Update } from "#middlewares/validators/{{ modelLowerCase }}";
export default (_express: Application) => {
const {{ modelLowerCase }}Provider = new {{ modelUpperCase }}Provider();
return <Resource>{
/**
* @openapi
* /{{{ apiPathLink }}}/{id}:
* get:
* tags: [{{ swaggerTag }}]
* summary: Get {{ modelLowerCase }} by ID
* description: Retrieve a single {{ modelLowerCase }} record by its ID
* security:
* - BearerAuth: []
* parameters:
* - name: id
* in: path
* required: true
* description: {{ modelUpperCase }} ID
* schema:
* type: string
* responses:
* 200:
* description: {{ modelUpperCase }} retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/{{ modelUpperCase }}"
* 404:
* description: {{ modelUpperCase }} not found
*/
get: {
middleware: [verify, queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await {{ modelLowerCase }}Provider.getById({ id: req.params.id! });
return res.sendOk({ data });
} catch (error) {
await {{ modelLowerCase }}Provider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /{{{ apiPathLink }}}/{id}:
* put:
* tags: [{{ swaggerTag }}]
* summary: Update {{ modelLowerCase }} by ID
* description: Update a single {{ modelLowerCase }} record by its ID
* security:
* - BearerAuth: []
* parameters:
* - name: id
* in: path
* required: true
* description: {{ modelUpperCase }} ID
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/{{ modelUpperCase }}Mutate"
* responses:
* 200:
* description: {{ modelUpperCase }} updated successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/{{ modelUpperCase }}"
* 404:
* description: {{ modelUpperCase }} not found
*/
put: {
middleware: [verify, queryModifier, validateId, validate{{ modelUpperCase }}Update],
handler: async (req: Req, res: Res) => {
try {
const data = await {{ modelLowerCase }}Provider.put(req.params.id!, { ...req.body, updated_at: new Date(), updated_by: req.user?.id || null });
return res.sendOk({ data });
} catch (error) {
await {{ modelLowerCase }}Provider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /{{{ apiPathLink }}}/{id}:
* delete:
* tags: [{{ swaggerTag }}]
* summary: Delete {{ modelLowerCase }} by ID
* description: Delete a single {{ modelLowerCase }} record by its ID
* security:
* - BearerAuth: []
* parameters:
* - name: id
* in: path
* required: true
* description: {{ modelUpperCase }} ID
* schema:
* type: string
* responses:
* 200:
* description: {{ modelUpperCase }} deleted successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* type: boolean
* example: true
* 404:
* description: {{ modelUpperCase }} not found
*/
delete: {
middleware: [verify, queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await {{ modelLowerCase }}Provider.delete(req.params.id!);
return res.sendOk({ data });
} catch (error) {
await {{ modelLowerCase }}Provider.logError(error as Error);
return res.error(error);
}
},
},
};
};
\ No newline at end of file
import { BaseProvider } from "#templates/base/provider";
import { {{ modelUpperCase }} } from "#models/{{ modelUpperCase }}";
import { {{ modelUpperCase }}Attributes, {{ modelUpperCase }}CreationAttributes } from "#models/{{ modelUpperCase }}";
import { GenericError } from "#interfaces/error/generic";
export class {{ modelUpperCase }}Provider extends BaseProvider<{{ modelUpperCase }}> {
constructor(skipInitDb?: boolean) {
super("{{ modelUpperCase }}", skipInitDb);
}
async createWithValidation(data: {{ modelUpperCase }}CreationAttributes): Promise<{{ modelUpperCase }}> {
try {
if (!data) throw new GenericError({ vi: "Dữ liệu không được để trống", en: "Data cannot be empty" }, "VALIDATION_ERROR", 400);
return this.create(data);
} catch (error) {
if (error instanceof GenericError) throw error;
await this.logError(error as Error);
throw new GenericError({ vi: "Lỗi khi tạo dữ liệu", en: "Error creating data" }, "DATABASE_ERROR", 500, { originalError: error });
}
}
async updateWithValidation(id: string, data: Partial<{{ modelUpperCase }}Attributes>): Promise<{{ modelUpperCase }} | null> {
try {
if (!id) throw new GenericError({ vi: "ID không được để trống", en: "ID cannot be empty" }, "VALIDATION_ERROR", 400);
return this.updateById(id, data);
} catch (error) {
if (error instanceof GenericError) throw error;
await this.logError(error as Error);
throw new GenericError({ vi: "Lỗi khi cập nhật dữ liệu", en: "Error updating data" }, "DATABASE_ERROR", 500, { id, originalError: error });
}
}
{{#hasStatus}}
async findByStatus(status: string): Promise<{{ modelUpperCase }}[]> {
try {
return this.findAll({ where: { status } as any });
} catch (error) {
await this.logError(error as Error);
throw new GenericError({ vi: "Lỗi khi tìm kiếm theo trạng thái", en: "Error finding by status" }, "DATABASE_ERROR", 500, { status, originalError: error });
}
}
{{/hasStatus}}
{{#hasIsDeleted}}
async softDelete(id: string): Promise<boolean> {
try {
if (!id) throw new GenericError({ vi: "ID không được để trống", en: "ID cannot be empty" }, "VALIDATION_ERROR", 400);
const result = await this.updateById(id, { isDeleted: true } as any);
return !!result;
} catch (error) {
if (error instanceof GenericError) throw error;
await this.logError(error as Error);
throw new GenericError({ vi: "Lỗi khi xóa dữ liệu", en: "Error deleting data" }, "DATABASE_ERROR", 500, { id, originalError: error });
}
}
{{/hasIsDeleted}}
{{#hasIsActive}}
async findActive(): Promise<{{ modelUpperCase }}[]> {
try {
return this.findAll({ where: { isActive: true } as any });
} catch (error) {
await this.logError(error as Error);
throw new GenericError({ vi: "Lỗi khi tìm kiếm dữ liệu active", en: "Error finding active data" }, "DATABASE_ERROR", 500, { originalError: error });
}
}
{{/hasIsActive}}
}
\ No newline at end of file
import Joi from "joi";
import { validateJoi } from ".";
// {{ modelUpperCase }} validation schemas
export const {{ modelLowerCase }}Schemas = {
create: Joi.object({
body: Joi.object({
// TODO: Add validation rules for {{ modelLowerCase }} creation
// Example:
// name: Joi.string().min(1).max(100).required(),
// description: Joi.string().optional(),
}),
params: Joi.object(),
query: Joi.object(),
}),
update: Joi.object({
params: Joi.object({
id: Joi.string().required().messages({
'any.required': '{{ modelUpperCase }} ID là bắt buộc'
}),
}),
body: Joi.object({
// TODO: Add validation rules for {{ modelLowerCase }} update
// All fields should be optional for partial updates
}),
query: Joi.object(),
}),
};
// Pre-built {{ modelLowerCase }} validators
export const validate{{ modelUpperCase }}Create = validateJoi({{ modelLowerCase }}Schemas.create);
export const validate{{ modelUpperCase }}Update = validateJoi({{ modelLowerCase }}Schemas.update);
\ No newline at end of file
const { readdirSync, readFileSync, existsSync } = require("fs");
const { resolve } = require("path");
/** @returns {Promise<import("swagger-jsdoc").OAS3Options>} */
module.exports = async () => {
const root = resolve(__dirname, "../../../src");
const schemaPath = `${__dirname}/schemas`;
const generatedSchemas = {};
if (existsSync(schemaPath)) {
const files = readdirSync(schemaPath);
console.log("Loading schema files:", files);
for (const fileName of files) {
const filePath = `${schemaPath}/${fileName}`;
const content = JSON.parse(readFileSync(filePath, "utf8"));
Object.assign(generatedSchemas, content);
console.log(`Loaded ${Object.keys(content).length} schemas from ${fileName}`);
}
}
console.log("Total schemas loaded:", Object.keys(generatedSchemas).length);
// Load all schemas from templates/swagger
const swaggerTemplatePath = resolve(root, "templates/swagger");
const loadSchemasRecursively = (dirPath) => {
const items = readdirSync(dirPath, { withFileTypes: true });
for (const item of items) {
const fullPath = resolve(dirPath, item.name);
if (item.isDirectory()) {
loadSchemasRecursively(fullPath);
} else if (item.name === "schemas.js") {
try {
const schemas = require(fullPath);
Object.assign(generatedSchemas, schemas);
console.log(`Loaded schemas from ${fullPath}`);
} catch (error) {
console.warn(`Failed to load schemas from ${fullPath}:`, error.message);
}
}
}
};
loadSchemasRecursively(swaggerTemplatePath);
// Alias Response as ApiResponse for compatibility
if (generatedSchemas.Response) {
generatedSchemas.ApiResponse = generatedSchemas.Response;
}
return {
definition: {
openapi: "3.0.3",
info: {
version: process.env.PROJECT_VERSION || "1.0.0",
title: (process.env.PROJECT_NAME || "Backend Template") + " API",
description: "Generated API documentation",
},
servers: [
{
url: process.env.BACKEND_URL || "http://localhost:3000",
description: "API Server",
},
],
components: {
securitySchemes: {
BearerAuth: {
type: "apiKey",
in: "header",
name: "Authorization",
},
},
schemas: generatedSchemas,
responses: require("../../../src/templates/swagger/common/responses"),
parameters: require("../../../src/templates/swagger/common/parameters"),
},
},
apis: process.env.NODE_ENV === "development" ? ["src/controllers/**/*.ts"] : ["dist/controllers/**/*.js"],
};
};
This diff is collapsed.
#!/usr/bin/env node
/**
* Static Route Generation Script
* Scans controller files and generates a static route manifest
* This replaces the dynamic file system scanning at runtime
*/
const fs = require("fs");
const path = require("path");
const Mustache = require("mustache");
/**
* Static Route Generator Class
*
* Provides functionality for scanning controller directories and generating
* static route manifests for Express.js applications. This replaces dynamic
* file system scanning at runtime with a pre-generated manifest.
*/
class StaticRouteGenerator {
/**
* Initializes and runs the complete route generation process
*/
static init(options = {}) {
const {
log = true,
controllersDir = path.resolve(process.cwd(), "src/controllers"),
outputPath = path.resolve(process.cwd(), "src/routes.ts"),
} = options;
if (log) console.log("🔍 Scanning controllers...");
const routes = this.scanControllers(controllersDir);
if (log) console.log(`📝 Generating route manifest with ${routes.length} routes...`);
this.generateManifest(routes, outputPath);
if (log) console.log(`✅ Route manifest generated at: ${outputPath}`);
}
/**
* Scans controller directories and builds route configuration
*/
static scanControllers(controllersDir) {
const routes = [];
if (!fs.existsSync(controllersDir)) {
console.warn(`⚠️ Controllers directory not found: ${controllersDir}`);
return routes;
}
const scanDir = (dir, prefix = "") => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
// Recursively scan subdirectories
scanDir(fullPath, prefix + item + "/");
} else if (item === "index.ts" || item === "index.js") {
// Found a controller index file
const routePath = prefix.slice(0, -1); // Remove trailing slash
const controllerPath = path
.relative(path.resolve(process.cwd(), "src"), fullPath)
.replace(/\\/g, "/")
.replace(".ts", "")
.replace(".js", "");
routes.push({
path: routePath,
controller: controllerPath,
methods: this.extractMethods(fullPath),
});
}
}
};
scanDir(controllersDir);
return routes;
}
/**
* Extracts HTTP methods from controller file
*/
static extractMethods(controllerFile) {
try {
const content = fs.readFileSync(controllerFile, "utf8");
// Check if this is a bulk or CRUD controller by checking for {id}.ts file
const dir = path.dirname(controllerFile);
const hasIdFile = fs.existsSync(path.join(dir, "{id}.ts"));
const isBulkController = !hasIdFile;
const methods = [];
// Always present methods
if (content.includes("post:")) methods.push("POST");
if (content.includes("put:")) methods.push("PUT");
if (content.includes("delete:")) methods.push("DELETE");
// GET is only present in non-bulk controllers
if (!isBulkController && content.includes("get:")) methods.push("GET");
return methods;
} catch (error) {
console.warn(`⚠️ Could not extract methods from ${controllerFile}:`, error.message);
return [];
}
} /**
* Generates the route manifest file
*/
static generateManifest(routes, outputPath) {
const template = `/**
* Auto-generated route manifest
* Generated on: {{timestamp}}
* Do not edit manually - use 'pnpm gen-routes' to regenerate
*/
export interface RouteConfig {
path: string;
controller: string;
methods: string[];
}
export const routes: RouteConfig[] = [
{{#routes}} {
path: "{{{path}}}",
controller: "{{{controller}}}",
methods: [{{#methods}}"{{.}}",{{/methods}}]
},
{{/routes}}];
export default routes;
`;
const data = {
routes,
timestamp: new Date().toISOString(),
};
const output = Mustache.render(template, data);
// Ensure output directory exists
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, output, "utf8");
}
}
// CLI interface
if (require.main === module) {
const args = process.argv.slice(2);
const options = {};
// Parse command line arguments
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--controllers" && args[i + 1]) {
options.controllersDir = args[i + 1];
i++;
} else if (arg === "--output" && args[i + 1]) {
options.outputPath = args[i + 1];
i++;
} else if (arg === "--quiet") {
options.log = false;
}
}
StaticRouteGenerator.init(options);
}
module.exports = StaticRouteGenerator;
This diff is collapsed.
const { mkdirSync, writeFileSync } = require("fs");
const { resolve } = require("path");
const swaggerJSDoc = require("swagger-jsdoc");
const getSwaggerConfig = require("../openapi");
// Generate swagger output
const generateSwagger = async (storagePath) => {
try {
const config = await getSwaggerConfig();
const spec = swaggerJSDoc(config);
const swaggerServePath = `${storagePath}/swagger/`;
mkdirSync(swaggerServePath, { recursive: true });
writeFileSync(`${swaggerServePath}/swagger-output.json`, JSON.stringify(spec, null, 2));
} catch (error) {
console.error("Error generating Swagger:", error);
throw error;
}
};
module.exports = { generateSwagger };
{
"watch": ["src"],
"ext": "ts,mjs,json",
"exec": "npm run build && cross-env NODE_ENV=development node dist/index.js",
"env": {
"NODE_ENV": "development"
},
"stdout": true,
"stderr": true,
"legacyWatch": true
}
{
"name": "backend-template",
"version": "1.0.0",
"description": "A flexible and modular backend template built with TypeScript, Node.js, and Express.js for rapid API development",
"main": "dist/index.js",
"engines": {
"node": ">=20.1.0",
"npm": ">=10.7.0"
},
"scripts": {
"-----------------DEVELOPMENT------------------": "",
"dev": "cross-env NODE_ENV=development nodemon",
"start:dev": "cross-env NODE_ENV=development nodemon",
"-----------------BUILDING------------------": "",
"build": "node lib/build/index.cjs",
"build:dev": "cross-env NODE_ENV=development npm run build",
"build:staging": "cross-env NODE_ENV=staging npm run build",
"build:prod": "cross-env NODE_ENV=production npm run build",
"clean": "rimraf dist && rimraf storage\\logs\\*.log",
"clean-install": "rimraf node_modules pnpm-lock.yaml && pnpm install",
"-----------------PRODUCTION------------------": "",
"start": "cross-env NODE_ENV=production node dist/index.js",
"start:staging": "cross-env NODE_ENV=staging node dist/index.js",
"start:prod": "cross-env NODE_ENV=production node dist/index.js",
"-----------------TESTING------------------": "",
"test": "node --test --require ts-node/register tests/*.test.ts",
"test:watch": "node --test --watch --require ts-node/register tests/*.test.ts",
"-----------------CODE QUALITY------------------": "",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"format": "prettier . --write",
"prettier:fix": "prettier . --write",
"type-check": "tsc --noEmit",
"lint-staged": "lint-staged -c ./config/lint-staged.config.js -v",
"-----------------GENERATOR------------------": "",
"gen-api": "cross-env NODE_ENV=development node lib/generator/api --crud",
"gen-api-bulk": "cross-env NODE_ENV=development node lib/generator/api --bulk",
"gen-providers": "cross-env NODE_ENV=development node lib/generator/sequelize-auto/index.js mode=providers-from-template",
"gen-providers:force": "cross-env NODE_ENV=development node lib/generator/sequelize-auto/index.js mode=providers-from-template --force",
"gen-db": "cross-env NODE_ENV=development node lib/generator/sequelize-auto/index.js mode=full",
"gen-db:force": "cross-env NODE_ENV=development node lib/generator/sequelize-auto/index.js mode=full --force",
"gen-db:clean": "rimraf src/models src/providers && npm run gen-db",
"gen-routes": "cross-env NODE_ENV=development node lib/generator/routes",
"-----------------DATABASE------------------": "",
"db:create": "npx sequelize-cli db:create",
"db:drop": "npx sequelize-cli db:drop",
"db:migrate": "npx sequelize-cli db:migrate",
"db:migrate:undo": "npx sequelize-cli db:migrate:undo",
"db:seed": "npx sequelize-cli db:seed:all",
"db:seed:undo": "npx sequelize-cli db:seed:undo:all",
"db:reset": "npm run db:drop && npm run db:create && npm run db:migrate && npm run db:seed",
"-----------------DOCKER------------------": "",
"docker:build": "docker build -t backend-template .",
"docker:build:dev": "docker build --target development -t backend-template:dev .",
"docker:build:prod": "docker build --target production -t backend-template:prod .",
"docker:run": "docker run -p 3000:3000 --env-file .env backend-template",
"docker:dev": "NODE_ENV=development docker-compose up --build",
"docker:dev:detach": "NODE_ENV=development docker-compose up --build -d",
"docker:prod": "NODE_ENV=production docker-compose up --build -d",
"docker:stop": "docker-compose down",
"docker:logs": "docker-compose logs -f app",
"docker:clean": "docker-compose down -v --remove-orphans && docker system prune -f",
"-----------------UTILITIES------------------": "",
"swagger:generate": "node -e \"const swaggerJSDoc = require('swagger-jsdoc'); const fs = require('fs'); const config = require('./src/templates/swagger/config'); const specs = swaggerJSDoc(config); fs.writeFileSync('./storage/swagger/swagger-output.json', JSON.stringify(specs, null, 2)); console.log('Swagger documentation generated at ./storage/swagger/swagger-output.json');\"",
"copyEnv": "node -e \"require('fs').copyFileSync('.env', 'dist/.env')\"",
"copyTemplates": "node -e \"require('fs').cpSync('./src/templates', './dist/templates', {recursive: true})\"",
"copyConfig": "node -e \"require('fs').cpSync('./src/config', './dist/config', {recursive: true})\"",
"copyAssets": "npm run copyConfig"
},
"keywords": [
"typescript",
"nodejs",
"express",
"api",
"template",
"backend",
"rest"
],
"author": "MeU Team",
"license": "ISC",
"dependencies": {
"@types/joi": "^17.2.3",
"bcryptjs": "^3.0.3",
"cli-color": "^2.0.4",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"express": "^4.22.1",
"express-automatic-routes": "^1.1.0",
"express-validator": "^7.3.1",
"joi": "^18.0.2",
"jsonwebtoken": "^9.0.3",
"module-alias": "^2.2.3",
"multer": "^2.0.2",
"mustache": "^4.2.0",
"mv": "^2.1.1",
"nconf": "^0.13.0",
"node-schedule": "^2.1.1",
"nodemailer": "^6.10.1",
"pg": "^8.16.3",
"sequelize": "^6.37.7",
"sharp": "^0.34.5",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"ts-import": "5.0.0-beta.1"
},
"optionalDependencies": {
"sequelize-auto": "^0.8.8"
},
"devDependencies": {
"@commitlint/cli": "^20.2.0",
"@commitlint/config-conventional": "^20.2.0",
"@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/cli-color": "^2.0.6",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/module-alias": "^2.0.4",
"@types/multer": "^2.0.0",
"@types/mustache": "^4.2.6",
"@types/mv": "^2.1.4",
"@types/nconf": "^0.10.7",
"@types/node": "^24.10.1",
"@types/node-schedule": "^2.1.8",
"@types/nodemailer": "^7.0.4",
"@types/sharp": "^0.32.0",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"chalk": "^5.6.2",
"cross-env": "^10.1.0",
"esbuild": "^0.27.1",
"eslint": "^9.39.1",
"husky": "^9.1.7",
"jsonminify": "^0.4.2",
"lint-staged": "^16.2.7",
"nodemon": "^3.1.11",
"prettier": "^3.7.4",
"rimraf": "^6.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"pnpm": {
"overrides": {
"multer": "^2.0.0",
"glob": "^10.0.0",
"inflight": "^1.0.6",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0"
}
}
}
This diff is collapsed.
This diff is collapsed.
-- =========================================
-- Token Encryption Functions
-- PostgreSQL pgcrypto functions for JWT token encryption
-- =========================================
-- Enable pgcrypto extension (required for encryption)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =========================================
-- ENCRYPTION FUNCTIONS
-- =========================================
-- Function to encrypt token using AES-256
CREATE OR REPLACE FUNCTION encrypt_token(
plain_token TEXT,
encryption_key TEXT
) RETURNS BYTEA AS $$
DECLARE
encrypted_token BYTEA;
BEGIN
-- Validate inputs
IF plain_token IS NULL OR plain_token = '' THEN
RAISE EXCEPTION 'Plain token cannot be null or empty';
END IF;
IF encryption_key IS NULL OR LENGTH(encryption_key) < 32 THEN
RAISE EXCEPTION 'Encryption key must be at least 32 characters long';
END IF;
-- Encrypt using AES-256-CBC
encrypted_token := pgp_sym_encrypt(plain_token, encryption_key, 'cipher-algo=aes256');
RETURN encrypted_token;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =========================================
-- DECRYPTION FUNCTIONS
-- =========================================
-- Function to decrypt token using AES-256
CREATE OR REPLACE FUNCTION decrypt_token(
encrypted_token BYTEA,
encryption_key TEXT
) RETURNS TEXT AS $$
DECLARE
decrypted_token TEXT;
BEGIN
-- Validate inputs
IF encrypted_token IS NULL THEN
RAISE EXCEPTION 'Encrypted token cannot be null';
END IF;
IF encryption_key IS NULL OR LENGTH(encryption_key) < 32 THEN
RAISE EXCEPTION 'Encryption key must be at least 32 characters long';
END IF;
-- Decrypt using AES-256-CBC
decrypted_token := pgp_sym_decrypt(encrypted_token, encryption_key, 'cipher-algo=aes256');
RETURN decrypted_token;
EXCEPTION
WHEN OTHERS THEN
-- Return null for invalid encrypted data instead of throwing error
-- This allows graceful handling of corrupted/invalid tokens
RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =========================================
-- UTILITY FUNCTIONS
-- =========================================
-- Function to validate encryption key format
CREATE OR REPLACE FUNCTION validate_encryption_key(
key_text TEXT
) RETURNS BOOLEAN AS $$
BEGIN
-- Check if key is not null and has minimum length
IF key_text IS NULL OR LENGTH(key_text) < 32 THEN
RETURN FALSE;
END IF;
-- Additional validation can be added here
-- For example, check for specific character requirements
RETURN TRUE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- =========================================
-- PERMISSIONS
-- =========================================
-- Grant execute permissions to the database user
-- Note: Adjust 'your_app_user' to match your actual database user
-- GRANT EXECUTE ON FUNCTION encrypt_token(TEXT, TEXT) TO your_app_user;
-- GRANT EXECUTE ON FUNCTION decrypt_token(BYTEA, TEXT) TO your_app_user;
-- GRANT EXECUTE ON FUNCTION validate_encryption_key(TEXT) TO your_app_user;
-- =========================================
-- TESTING FUNCTIONS (Optional - for development)
-- =========================================
-- Test encryption/decryption roundtrip
CREATE OR REPLACE FUNCTION test_token_encryption(
test_token TEXT DEFAULT 'test.jwt.token.here',
test_key TEXT DEFAULT 'TOKEN_ENCRYPTION_KEY'
) RETURNS TABLE (
original_token TEXT,
encrypted_token BYTEA,
decrypted_token TEXT,
success BOOLEAN
) AS $$
DECLARE
encrypted BYTEA;
decrypted TEXT;
BEGIN
-- Test encryption
BEGIN
encrypted := encrypt_token(test_token, test_key);
decrypted := decrypt_token(encrypted, test_key);
RETURN QUERY SELECT
test_token,
encrypted,
decrypted,
(decrypted = test_token) AS success;
EXCEPTION
WHEN OTHERS THEN
RETURN QUERY SELECT
test_token,
NULL::BYTEA,
NULL::TEXT,
FALSE;
END;
END;
$$ LANGUAGE plpgsql;
-- =========================================
-- COMMENTS
-- =========================================
COMMENT ON FUNCTION encrypt_token(TEXT, TEXT) IS 'Encrypts a JWT token using AES-256 encryption with pgcrypto';
COMMENT ON FUNCTION decrypt_token(BYTEA, TEXT) IS 'Decrypts an AES-256 encrypted JWT token using pgcrypto';
COMMENT ON FUNCTION validate_encryption_key(TEXT) IS 'Validates encryption key format and length';
COMMENT ON FUNCTION test_token_encryption(TEXT, TEXT) IS 'Tests encryption/decryption roundtrip (development only)';
-- =========================================
-- USAGE EXAMPLES
-- =========================================
/*
-- Basic usage:
SELECT encrypt_token('my.jwt.token', 'my-32-char-encryption-key-here');
-- Decrypt:
SELECT decrypt_token(
encrypt_token('my.jwt.token', 'my-32-char-encryption-key-here'),
'my-32-char-encryption-key-here'
);
-- Test roundtrip:
SELECT * FROM test_token_encryption();
-- Validate key:
SELECT validate_encryption_key('my-32-char-encryption-key-here');
*/
\ No newline at end of file
export enum Language {
vi = "vi",
en = "en",
}
export const DEFAULTS = {
DEFAULT_ERROR_CODE: -999,
DEFAULT_ERROR_MESSAGE: "Đã có lỗi xảy ra, vui lòng thử lại sau",
DEFAULT_ERROR_MESSAGE_EN: "There has been a problem with the system, please try again later",
DEFAULT_SUCCESS_MESSAGE: "Thành công",
DEFAULT_SUCCESS_MESSAGE_EN: "Success",
};
export const LOG = {
LOG_CHANNEL_TYPE: "centerAPI",
LOG_TYPE: "log",
LOG_INFO_CATEGORY: "info",
LOG_ERROR_CATEGORY: "error",
LOG_TRANS_CATEGORY: "trans",
LOG_TRACE_CATEGORY: "trace",
LOG_DB_CATEGORY: "db",
};
export const PATHS = {
BUILD_PATH: "dist",
LOG_PATH: "logs",
STORE_PATH: "storage",
};
export const FOLDERS = [
"config",
"controllers",
"dto",
"interfaces",
"middlewares",
"models",
"providers",
"services",
"templates",
];
export const ERROR = {
ERROR_TYPE: {
API: "API",
DB: "DB",
},
ErrorConfiguration: {
API: {
[-999]: "There seems to be a problem performing your action, please try again.",
[104]: "The voucher transaction is not found.",
[401]: "Unauthorize",
[400]: "Validation Error",
[302]: "Cập nhật tài khoản",
},
DB: {
[410]: "Page must be larger or equal to 0",
[411]: "PageSize must be a positive number",
},
},
};
export const MIME = {
MIME_TYPES: {
IMAGE: ["image/jpeg", "image/png", "image/gif"],
VIDEO: [
"video/x-flv",
"video/mp4",
"application/x-mpegURL",
"video/MP2T",
"video/3gpp",
"video/quicktime",
"video/x-msvideo",
"video/x-ms-wmv",
],
},
};
export const SWAGGER = { SWAGGER_ROUTER: "/swagger/index", SWAGGER_OUTPUT: "/swagger/swagger-output.json" };
export default {
FOLDERS,
...DEFAULTS,
...LOG,
...PATHS,
...SWAGGER,
...MIME,
...ERROR,
};
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment