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
# Backend Template - TypeScript + Node.js
A flexible and modular backend template built with TypeScript, Node.js, and Express.js. Designed for rapid development of scalable REST APIs with best practices.
## 🚀 Quick Start
### Prerequisites
- Node.js >= 20.1.0
- npm >= 10.7.0 or pnpm
- PostgreSQL (optional, can be configured for other databases)
### Installation
1. **Clone and setup:**
```bash
git clone <repository-url>
cd backend-template
npm install
# or
pnpm install
```
2. **Environment configuration:**
```bash
cp .env.example .env
# Edit .env with your configuration
```
**Automated Setup (Alternative):**
You can use the provided setup script to automate the initial configuration:
```bash
node setup.js
```
This script will:
- Create `.env` file from `.env.example` (if not exists)
- Install dependencies (if `node_modules` doesn't exist)
- Create necessary storage directories (`storage/uploads/*`, `storage/logs`, `storage/swagger`)
- Display next steps for configuration
3. **Database setup (optional):**
```bash
# Generate models and providers from existing database
npm run gendb
# Force regenerate (overwrite existing files)
npm run gendb:force
# Clean and regenerate from scratch
npm run gendb:clean
```
4. **Development:**
```bash
npm run start:dev
```
5. **Build for production:**
```bash
npm run build
npm run start
```
## 📚 Documentation
This project includes comprehensive guides for development:
- **[API Response Guide](guidelines/API_RESPONSE_GUIDE.md)** - Unified response handling system with success/error formatting
- **[Query Modifier Guide](guidelines/QUERY_MODIFIER_GUIDE.md)** - Automatic query parameter transformation and database filtering
- **[Authentication Guide](guidelines/AUTHENTICATION_GUIDE.md)** - Authentication and authorization implementation
- **[Database Generation Guide](guidelines/DATABASE_GENERATION_GUIDE.md)** - Automated model and provider generation from database schema
## 📁 Project Structure
```
backend-template/
├── lib/
│ ├── build/ # Build configuration
│ └── generator/ # Code generators
│ ├── api/ # API generator
│ └── templates/ # Generator templates
├── src/
│ ├── constants/ # Application constants
│ ├── controllers/ # API route handlers
│ │ ├── api/ # API endpoints (auto-generated)
│ │ └── logs/ # Log management endpoints
│ ├── interfaces/ # TypeScript interfaces
│ ├── middlewares/ # Express middlewares
│ ├── models/ # Database models (auto-generated)
│ ├── providers/ # Data access layer
│ ├── services/ # Business logic services
│ │ ├── data-handlers/ # Data processing utilities
│ │ ├── database/ # Database utilities
│ │ ├── file-system-handlers/ # File operations
│ │ ├── mailService.ts # Email service
│ │ └── scheduleJobService.ts # Cron jobs
│ └── templates/ # Template files
├── storage/ # Static files & generated docs
├── .env.example # Environment template
├── Dockerfile # Docker build config
├── docker-compose-dev.yaml # Development compose
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript config
└── nodemon.json # Development config
```
## 🛠️ Technologies
### Core
- **TypeScript**: Type-safe JavaScript
- **Node.js**: Runtime environment
- **Express.js**: Web framework
- **Sequelize**: ORM for database operations
### API & Documentation
- **Swagger/OpenAPI 3.1**: API documentation
- **express-automatic-routes**: Auto-routing from file structure
- **express-validator**: Input validation
### Authentication & Security
- **jsonwebtoken**: JWT authentication
- **bcryptjs**: Password hashing
- **crypto-js**: Encryption utilities
### File & Media Handling
- **multer**: File upload handling
- **sharp**: Image processing
- **fluent-ffmpeg**: Media processing
### Communication
- **nodemailer**: Email service
- **node-schedule**: Cron jobs
### Development Tools
- **esbuild**: Fast bundler
- **nodemon**: Hot reload development
- **prettier**: Code formatting
## 🔧 Available Scripts
```bash
# Development
npm run start:dev # Start development server with hot reload
npm run test # Run tests
# Building
npm run build # Build for production
npm run start # Start production server
# Code Generation
npm run gen-api # Generate CRUD API endpoints
npm run gen-api-bulk # Generate bulk API endpoints
npm run gendb # Generate models from database
# Utilities
npm run prettier:fix # Format code
```
## 🏗️ Architecture
### Layered Architecture
1. **Controllers**: Handle HTTP requests/responses
2. **Services**: Business logic and external integrations
3. **Providers**: Data access layer (database operations)
4. **Models**: Database schema representations
### Key Features
- **Modular Design**: Easy to add/remove features
- **Auto-generation**: Generate CRUD APIs and models
- **Type Safety**: Full TypeScript support
- **Docker Ready**: Containerized deployment
- **Scalable**: Cluster support for production
## 🚀 Creating Your First API
### 1. Generate Database Models
```bash
npm run gendb
```
This will scan your database and generate Sequelize models.
### 2. Generate API Endpoints
Choose the appropriate generator based on your needs:
#### For CRUD Operations (single records):
```bash
npm run gen-api <model> [domain] [apiPath] [swaggerTag]
```
Example: `npm run gen-api user user-management`
#### For Bulk Operations (multiple records):
```bash
npm run gen-api-bulk <model> [domain] [apiPath] [swaggerTag]
```
Example: `npm run gen-api-bulk user user-management`
Follow the prompts to create endpoints for your models.
### 3. Customize Business Logic
- Add custom methods in `providers/`
- Implement business logic in `services/`
- Add custom routes in `controllers/`
## 🔒 Security Features
- JWT-based authentication
- Password hashing with bcrypt
- Input validation and sanitization
- CORS configuration
- Rate limiting (configurable)
## 📦 Optional Features
You can enable/disable features by installing/removing dependencies:
- **Media Processing**: `fluent-ffmpeg`, `sharp`
- **Email**: `nodemailer`
- **File Upload**: `multer`
- **Scheduling**: `node-schedule`
## 🐳 Docker Deployment
This template includes a unified Docker setup for both development and production environments using a single `docker-compose.yaml` file.
### Prerequisites
- Docker Engine 20.10+
- Docker Compose 2.0+
### Quick Start
1. **Copy environment file**:
```bash
cp .env.example .env
```
2. **Edit environment variables** in `.env`:
```bash
# Update database and other service configurations
DB_PASSWORD=your_secure_password
JWT_SECRET=your_super_secure_jwt_secret
```
3. **Start development environment**:
```bash
npm run docker:dev
```
### Development Environment
The development setup includes:
- **Hot reloading** with volume mounts
- **PostgreSQL database** with persistent storage
- **Redis cache** with append-only file
- **Health checks** for all services
- **Automatic dependency installation**
#### Development Commands
```bash
# Start development environment
npm run docker:dev
# Start in detached mode
npm run docker:dev:detach
# View logs
npm run docker:logs
# Stop development environment
npm run docker:stop
# Clean up (removes volumes and containers)
npm run docker:clean
```
### Production Environment
The production setup includes:
- **Multi-stage optimized builds**
- **Security hardening** with non-root user
- **Minimal attack surface** with Alpine Linux
- **Health checks** and monitoring
#### Production Commands
```bash
# Build and start production environment
npm run docker:prod
# View production logs
npm run docker:logs
# Stop production environment
npm run docker:stop
# Clean production environment
npm run docker:clean
```
### Docker Architecture
#### Unified Configuration
```
┌─────────────────┐ ┌─────────────────┐
│ App (Dev/Prod) │ │ PostgreSQL │
│ │ │ │
│ • Hot reload │◄──►│ • Port 5432 │
│ • Source mount │ │ • Persistent │
│ • Dev/Prod deps │ │ • Health check │
│ • Port 3000 │ └─────────────────┘
└─────────────────┘ ▲
▲ │
│ ┌─────────────────┐
└────────────┤ Redis │
│ │
│ • Port 6379 │
│ • AOF enabled │
│ • Health check │
└─────────────────┘
```
### Environment Configuration
#### Required Environment Variables
```bash
# Server
NODE_ENV=production # or development
PORT=3000
# Database
DB_HOST=db
DB_PORT=5432
DB_NAME=backend_template
DB_USER=postgres
DB_PASSWORD=your_secure_password
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
# JWT
JWT_SECRET=your_super_secure_jwt_secret
```
#### Service URLs (Docker)
When running in Docker, use service names as hosts:
- Database: `db:5432`
- Redis: `redis:6379`
- App: `app:3000`
### Health Checks
All services include health checks:
- **Application**: `GET /health` returns system status
- **Database**: PostgreSQL connection check
- **Redis**: PING command check
### Troubleshooting
#### Common Issues
1. **Port conflicts**:
```bash
# Check what's using ports
netstat -tulpn | grep :3000
# Change port in .env: PORT=3001
```
2. **Permission issues**:
```bash
# Fix storage permissions
sudo chown -R 1001:1001 storage/
```
3. **Database connection**:
```bash
# Check database logs
npm run docker:logs
```
4. **Build failures**:
```bash
# Clean and rebuild
npm run docker:clean
npm run docker:dev
```
#### Logs and Debugging
```bash
# View all service logs
docker-compose logs
# View specific service logs
docker-compose logs app
# Follow logs in real-time
docker-compose logs -f
# View container resource usage
docker stats
```
### Advanced Configuration
#### Environment-Specific Setup
Use `NODE_ENV` to control the deployment mode:
```bash
# Development
NODE_ENV=development docker-compose up
# Production
NODE_ENV=production docker-compose up -d
```
#### Custom Configuration
Override settings using environment variables in `.env`:
```bash
# Change ports
PORT=3001
DB_PORT=5433
REDIS_PORT=6380
# Database settings
DB_NAME=my_custom_db
DB_USER=my_user
DB_PASSWORD=my_secure_password
```
#### Scaling and Orchestration
For production scaling, consider:
- **Load balancer** (nginx, traefik)
- **Database clustering** (PostgreSQL streaming replication)
- **Redis clustering** or sentinel
- **Container orchestration** (Docker Swarm, Kubernetes)
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests: `npm test`
5. Format code: `npm run prettier:fix`
6. Submit a pull request
## 📄 License
ISC License - see LICENSE file for details.
## 👥 Authors
- MeU Team
---
**Note**: This template is designed to be flexible. Feel free to modify and adapt it to your project needs!
│ ├── dto
│ ├── middlewares
│ ├── models // database dto here, usually auto-generated
│ ├── providers
│ ├── services
│ ├── templates/
│ │ └── swagger-template.json // template used for swaggerJsDoc here
│ ├── index.ts // project's main entry point!
│ └── server.ts // swagger & server's startup functions.
├── .env
└── storage/
├── images
└── swagger/
└── swagger-output.json // generated at runtime
```
## 3. Workflow
1. Dev should read and analyze the assigned ticket, either via Jira or by an organized sheet
2. Dev should check if the model required for the ticket is available in "models"
1. If available, proceed to the next step
2. If not, run the following command: `npm run genDb`
3. Dev should check if the api path required for the ticket is available in "controllers"
1. If available, proceed to handling the required path
2. If the path is either "/" or "/{id}":
- For single record operations: `npm run gen-api <model> [domain]`
- For bulk operations: `npm run gen-api-bulk <model> [domain]`
- Replace `<model>` with the actual model name from the "models" folder
- Optional: add domain parameter for organizing APIs (e.g., `user-management`)
- Finally, it will create 2 files in the folder from the prefered API Link entered above, named "index.ts" & "{id}.ts"
```
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
# Authentication Guide
## Overview
The authentication system provides comprehensive user management with JWT tokens, session management, and role-based access control (RBAC). This system is designed for admin-only user creation (no self-registration) and includes advanced security features like 2FA, audit logging, and token rotation.
## Features
- **JWT Authentication**: Access and refresh token mechanism with rotation
- **Session Management**: Track active user sessions with device info and token versioning
- **Password Security**: bcrypt hashing with configurable rounds and history tracking
- **Two-Factor Authentication**: TOTP, SMS, and Email 2FA support
- **Rate Limiting**: Protection against brute force attacks
- **Role-Based Access Control**: User roles and permissions (user, admin, system_admin)
- **Admin-Only User Creation**: No self-registration, accounts created by system administrators
- **User Management**: Complete CRUD operations for user accounts
- **Audit Logging**: Comprehensive logging of authentication events
- **Security Monitoring**: Failed login tracking and account lockout
- **Unified Response Format**: Consistent API responses with multi-language support
## Database Schema
The authentication system uses PostgreSQL with 7 optimized tables:
### Users Table
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100) UNIQUE,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
phone VARCHAR(20),
avatar_url TEXT,
status user_status DEFAULT 'pending_verification',
role user_role DEFAULT 'user',
last_login_at TIMESTAMP WITH TIME ZONE,
password_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP WITH TIME ZONE,
-- 2FA fields
twofa_secret VARCHAR(255),
twofa_backup_codes TEXT[],
twofa_enabled BOOLEAN DEFAULT FALSE,
twofa_method VARCHAR(20) DEFAULT 'totp',
-- Password history (JSON array of recent hashes)
password_history JSONB DEFAULT '[]'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
```
### User Sessions Table
```sql
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token VARCHAR(255) UNIQUE NOT NULL,
refresh_token VARCHAR(255) UNIQUE NOT NULL,
token_version INTEGER DEFAULT 1,
device_info JSONB,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
refresh_expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
### Tokens Table (Password Reset)
```sql
CREATE TABLE tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type token_type NOT NULL,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
### Permissions Table (RBAC)
```sql
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
resource VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
### Role Permissions Table
```sql
CREATE TABLE role_permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
role user_role NOT NULL,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
### Authentication Audit Logs Table
```sql
CREATE TABLE auth_audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
ip_address INET,
user_agent TEXT,
device_info JSONB,
success BOOLEAN DEFAULT TRUE,
failure_reason TEXT,
session_id UUID REFERENCES user_sessions(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
## User Status Flow
Users follow this status lifecycle:
- `pending_verification`: Initial status when created by admin
- `active`: Activated and can login
- `inactive`: Deactivated by admin
- `suspended`: Temporarily suspended due to security issues
token_version INTEGER DEFAULT 1,
device_info JSONB,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMP NOT NULL,
refresh_expires_at TIMESTAMP NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
````
### Audit Logs Table
```sql
CREATE TABLE auth_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
ip_address INET,
user_agent TEXT,
device_info JSONB,
success BOOLEAN DEFAULT TRUE,
failure_reason TEXT,
session_id UUID REFERENCES user_sessions(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
````
### Two-Factor Authentication Table
```sql
CREATE TABLE user_2fa (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
secret VARCHAR(255),
backup_codes TEXT[],
enabled BOOLEAN DEFAULT FALSE,
method VARCHAR(20) DEFAULT 'totp',
phone VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Permissions Table
```sql
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
resource VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### User Permissions Table
```sql
CREATE TABLE user_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_by UUID REFERENCES users(id),
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, permission_id)
);
```
## API Endpoints
### Authentication Endpoints
#### Login
```http
POST /api/v1.0/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "securePassword123",
"device_info": {
"device_type": "mobile",
"os": "iOS"
}
}
```
**Response:**
```json
{
"message": "Đăng nhập thành công",
"message_en": "Login successful",
"responseData": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 900,
"refresh_expires_in": 604800,
"token_type": "Bearer",
"user": {
"id": "uuid",
"email": "user@example.com",
"first_name": "John",
"role": "user"
}
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
#### Refresh Token
```http
POST /api/v1.0/auth/refresh
Authorization: Bearer <refresh_token>
```
**Response:**
```json
{
"message": "Token refreshed successfully",
"message_en": "Token refreshed successfully",
"responseData": {
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"expires_in": 900,
"token_type": "Bearer"
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
#### Get Profile
```http
GET /api/v1.0/auth/me
Authorization: Bearer <access_token>
```
**Response:**
```json
{
"message": null,
"message_en": null,
"responseData": {
"id": "uuid",
"email": "user@example.com",
"username": "johndoe",
"first_name": "John",
"last_name": "Doe",
"phone": "+1234567890",
"role": "user",
"status": "active",
"twofa_enabled": false,
"last_login_at": "2025-12-07T09:00:00Z"
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
#### Change Password
```http
POST /api/v1.0/auth/change-password
Authorization: Bearer <access_token>
Content-Type: application/json
{
"old_password": "currentPassword",
"new_password": "newSecurePassword123"
}
```
**Response:**
```json
{
"message": "Mật khẩu đã được thay đổi thành công",
"message_en": "Password changed successfully",
"responseData": null,
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
#### Logout
```http
POST /api/v1.0/auth/logout
Authorization: Bearer <access_token>
```
#### Logout All Sessions
```http
POST /api/v1.0/auth/logout-all
Authorization: Bearer <access_token>
```
#### Enable 2FA
```http
POST /api/v1.0/auth/2fa/enable
Authorization: Bearer <access_token>
Content-Type: application/json
{
"method": "totp",
"phone": "+1234567890" // For SMS method
}
```
#### Verify 2FA
```http
POST /api/v1.0/auth/2fa/verify
Authorization: Bearer <access_token>
Content-Type: application/json
{
"code": "123456"
}
```
#### Disable 2FA
```http
POST /api/v1.0/auth/2fa/disable
Authorization: Bearer <access_token>
Content-Type: application/json
{
"password": "currentPassword"
}
```
## User Management Endpoints
### Get All Users
```http
GET /api/v1.0/users?page=1&pageSize=10&status=active&role=user&search=john
Authorization: Bearer <access_token> (Admin/System Admin only)
```
**Response:**
```json
{
"message": null,
"message_en": null,
"responseData": {
"rows": [
{
"id": "uuid",
"email": "user@example.com",
"username": "johndoe",
"first_name": "John",
"last_name": "Doe",
"status": "active",
"role": "user",
"created_at": "2025-12-07T08:00:00Z"
}
],
"count": 50,
"page": 1,
"pageSize": 10
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
### Get User by ID
```http
GET /api/v1.0/users/:id
Authorization: Bearer <access_token>
```
**Response:**
```json
{
"message": null,
"message_en": null,
"responseData": {
"id": "uuid",
"email": "user@example.com",
"username": "johndoe",
"first_name": "John",
"last_name": "Doe",
"phone": "+1234567890",
"status": "active",
"role": "user",
"last_login_at": "2025-12-07T09:00:00Z",
"created_at": "2025-12-07T08:00:00Z"
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
### Create User
```http
POST /api/v1.0/users
Authorization: Bearer <access_token> (System Admin only)
Content-Type: application/json
{
"email": "user@example.com",
"username": "johndoe",
"password": "securePassword123",
"first_name": "John",
"last_name": "Doe",
"phone": "+1234567890",
"role": "user"
}
```
**Response:**
```json
{
"message": "Người dùng đã được tạo thành công",
"message_en": "User created successfully",
"responseData": {
"id": "uuid",
"email": "user@example.com",
"username": "johndoe",
"first_name": "John",
"last_name": "Doe",
"status": "pending_verification",
"role": "user",
"created_at": "2025-12-07T10:00:00Z"
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
### Update User
```http
PUT /api/v1.0/users/:id
Authorization: Bearer <access_token>
Content-Type: application/json
{
"first_name": "John Updated",
"status": "active"
}
```
**Response:**
```json
{
"message": "Người dùng đã được cập nhật thành công",
"message_en": "User updated successfully",
"responseData": {
"id": "uuid",
"email": "user@example.com",
"first_name": "John Updated",
"status": "active"
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
_Note: Only system admins can change user role and status. Regular users can only update their own profile._
### Delete User
```http
DELETE /api/v1.0/users/:id
Authorization: Bearer <access_token> (System Admin only)
```
**Response:**
```json
{
"message": "Người dùng đã được xóa thành công",
"message_en": "User deleted successfully",
"responseData": null,
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
### Get User Statistics
```http
GET /api/v1.0/users/stats
Authorization: Bearer <access_token> (Admin/System Admin only)
```
**Response:**
```json
{
"message": null,
"message_en": null,
"responseData": {
"total_active_users": 45,
"admin_users": 3,
"regular_users": 42
},
"status": "success",
"timeStamp": "2025-12-07 10:00:00",
"violations": null
}
```
## Error Handling
The system uses unified error handling with multi-language support:
### Authentication Errors
```json
{
"message": "Email hoặc mật khẩu không đúng",
"message_en": "Invalid email or password",
"responseData": null,
"status": "fail",
"timeStamp": "2025-12-07 10:00:00",
"violations": [
{
"message": {
"vi": "Email hoặc mật khẩu không đúng",
"en": "Invalid email or password"
},
"type": "AUTHENTICATION_ERROR",
"code": 401,
"additionalData": null
}
]
}
```
### Validation Errors
```json
{
"message": "Dữ liệu đầu vào không hợp lệ",
"message_en": "Invalid input data",
"responseData": null,
"status": "fail",
"timeStamp": "2025-12-07 10:00:00",
"violations": [
{
"message": {
"vi": "Email không được để trống",
"en": "Email is required"
},
"type": "VALIDATION_ERROR",
"code": 400,
"additionalData": {
"field": "email"
}
}
]
}
```
### Permission Errors
```json
{
"message": "Truy cập bị từ chối",
"message_en": "Access denied",
"responseData": null,
"status": "fail",
"timeStamp": "2025-12-07 10:00:00",
"violations": [
{
"message": {
"vi": "Bạn không có quyền truy cập tài nguyên này",
"en": "You don't have permission to access this resource"
},
"type": "FORBIDDEN",
"code": 403,
"additionalData": null
}
]
}
```
## Environment Variables
Add these to your `.env` file:
```env
# JWT Configuration
JWT_SECRET=your_super_secure_jwt_secret_key_here_change_this_in_production
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Authentication
AUTH_RATE_LIMIT_WINDOW=15
AUTH_RATE_LIMIT_MAX_REQUESTS=5
SESSION_CLEANUP_INTERVAL=3600000
MAX_SESSIONS_PER_USER=5
# Security
BCRYPT_ROUNDS=12
```
## Middleware
### Authentication Middleware
```typescript
import { authenticate } from "../middlewares/auth";
// Protect routes
app.get("/protected", authenticate, (req, res) => {
// Access user info via req.user
res.json({ user: req.user });
});
```
### Authorization Middleware
```typescript
import { authorize } from "../middlewares/auth";
// Require specific role
app.get("/admin", authenticate, authorize(["admin"]), (req, res) => {
res.json({ message: "Admin access granted" });
});
// Require specific permission
app.get("/users", authenticate, authorize([], ["users.read"]), (req, res) => {
res.json({ message: "Can read users" });
});
```
## User Roles
- `user`: Basic user role with profile management permissions
- `admin`: Administrative role with user management permissions
- `system_admin`: System administrator with full access including user creation and role management
## Security Features
1. **Password Hashing**: bcrypt with 12 rounds
2. **JWT Tokens**: Short-lived access tokens (15min) with refresh tokens (7 days) and rotation
3. **Session Tracking**: Monitor active sessions with device info and token versioning
4. **Two-Factor Authentication**: TOTP, SMS, and Email methods
5. **Rate Limiting**: Prevent brute force attacks (5 requests per 15 minutes)
6. **Account Locking**: Lock accounts after failed login attempts
7. **Token Blacklisting**: Invalidate tokens on logout with version control
8. **Audit Logging**: Comprehensive logging of all authentication events
9. **Password History**: Prevent reuse of previous passwords
10. **Row Level Security**: Database-level access control
## Usage Examples
### Client-side Authentication
```javascript
// Login
const response = await fetch("/api/v1.0/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "user@example.com",
password: "password123",
}),
});
const { access_token, refresh_token } = await response.json();
// Store tokens
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
// Make authenticated requests
const userResponse = await fetch("/api/v1.0/auth/me", {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
```
### Token Refresh
```javascript
async function refreshAccessToken() {
const refreshToken = localStorage.getItem("refresh_token");
const response = await fetch("/api/v1.0/auth/refresh", {
method: "POST",
headers: {
Authorization: `Bearer ${refreshToken}`,
},
});
if (response.ok) {
const { access_token } = await response.json();
localStorage.setItem("access_token", access_token);
return access_token;
}
// Redirect to login if refresh fails
window.location.href = "/login";
}
```
## Database Setup
Run the authentication schema:
```bash
# Apply database migrations
psql -d your_database -f docker/auth-schema.sql
```
## Testing
```bash
# Run authentication tests
npm test -- --grep "AuthService"
```
## Security Best Practices
1. **Use HTTPS**: Always serve over SSL/TLS
2. **Secure Secrets**: Store JWT secrets securely, rotate regularly
3. **Rate Limiting**: Implement rate limiting on auth endpoints
4. **Monitor Sessions**: Regularly clean up expired sessions
5. **Audit Logs**: Log authentication events for security monitoring and compliance
6. **Password Policies**: Enforce strong password requirements and prevent reuse
7. **Token Expiration**: Use short-lived tokens with refresh mechanism and rotation
8. **Two-Factor Authentication**: Enable 2FA for administrative accounts
9. **Account Monitoring**: Track failed login attempts and suspicious activities
## Database Setup
Run the optimized authentication schema:
```bash
# Apply database schema
psql -d your_database -f sql/auth-schema.sql
```
## Usage Examples
### Admin Creating User
```javascript
// Admin creates user (status: pending_verification)
const response = await fetch("/api/v1.0/users", {
method: "POST",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "user@example.com",
username: "johndoe",
password: "securePassword123",
first_name: "John",
last_name: "Doe",
role: "user",
}),
});
const result = await response.json();
// result.responseData.status === 'pending_verification'
```
### Activating User
```javascript
// Admin activates user
await fetch(`/api/v1.0/users/${userId}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "active",
}),
});
```
### User Login (after activation)
```javascript
const response = await fetch("/api/v1.0/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "user@example.com",
password: "securePassword123",
}),
});
const { responseData } = await response.json();
localStorage.setItem("access_token", responseData.access_token);
localStorage.setItem("refresh_token", responseData.refresh_token);
```
## Migration Notes
This guide reflects the optimized authentication system with:
- Consolidated tables (7 tables instead of 10)
- Integrated 2FA and password history into users table
- Removed email verification (admin-only user creation)
- Unified response format with multi-language support
- Enhanced error handling with GenericError system
## Troubleshooting
### Common Issues
1. **Token Expired**: Use refresh token to get new access token
2. **Invalid Token**: Check JWT secret configuration
3. **Rate Limited**: Wait for rate limit window to reset
4. **Session Not Found**: User may have logged out from another device
### Debug Mode
Enable debug logging by setting:
```env
NODE_ENV=development
LOG_LEVEL=debug
```
# 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"],
};
};
{
"ActiveUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"primary_role_name": {
"type": "string"
},
"last_login_at": {
"type": "string",
"format": "date-time"
},
"created_at": {
"type": "string",
"format": "date-time"
}
}
},
"ActiveUserMutate": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"primary_role_name": {
"type": "string"
},
"last_login_at": {
"type": "string",
"format": "date-time"
}
}
},
"Permission": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"resource": {
"type": "string"
},
"action": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": "string"
},
"updated_by": {
"type": "string"
}
}
},
"PermissionMutate": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"resource": {
"type": "string"
},
"action": {
"type": "string"
}
}
},
"Role": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": "string"
},
"updated_by": {
"type": "string"
}
}
},
"RoleMutate": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
},
"RolePermission": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"permission_id": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"role_id": {
"type": "string"
}
}
},
"RolePermissionMutate": {
"type": "object",
"properties": {
"permission_id": {
"type": "string"
},
"role_id": {
"type": "string"
}
}
},
"User": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"phone": {
"type": "string"
},
"avatar_url": {
"type": "string"
},
"status": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"deleted_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": "string"
},
"updated_by": {
"type": "string"
}
}
},
"UserAuth": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"user_id": {
"type": "string"
},
"password_hash": {
"type": "string"
},
"last_login_at": {
"type": "string",
"format": "date-time"
},
"password_changed_at": {
"type": "string",
"format": "date-time"
},
"login_attempts": {
"type": "number"
},
"locked_until": {
"type": "string",
"format": "date-time"
},
"twofa_secret": {
"type": "string"
},
"twofa_backup_codes": {
"type": "string"
},
"twofa_enabled": {
"type": "boolean"
},
"twofa_method": {
"type": "string"
},
"password_history": {
"type": "object"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"UserAuthMutate": {
"type": "object",
"properties": {
"user_id": {
"type": "string"
},
"password_hash": {
"type": "string"
},
"last_login_at": {
"type": "string",
"format": "date-time"
},
"password_changed_at": {
"type": "string",
"format": "date-time"
},
"login_attempts": {
"type": "number"
},
"locked_until": {
"type": "string",
"format": "date-time"
},
"twofa_secret": {
"type": "string"
},
"twofa_backup_codes": {
"type": "string"
},
"twofa_enabled": {
"type": "boolean"
},
"twofa_method": {
"type": "string"
},
"password_history": {
"type": "object"
}
}
},
"UserMutate": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"phone": {
"type": "string"
},
"avatar_url": {
"type": "string"
},
"status": {
"type": "string"
},
"deleted_at": {
"type": "string",
"format": "date-time"
}
}
},
"UserRole": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"user_id": {
"type": "string"
},
"role_id": {
"type": "string"
},
"assigned_at": {
"type": "string",
"format": "date-time"
},
"assigned_by": {
"type": "string"
},
"is_primary": {
"type": "boolean"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"UserRoleDetail": {
"type": "object",
"properties": {
"user_id": {
"type": "string"
},
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"role_id": {
"type": "string"
},
"role_name": {
"type": "string"
},
"role_description": {
"type": "string"
},
"is_primary": {
"type": "boolean"
},
"assigned_at": {
"type": "string",
"format": "date-time"
},
"assigned_by": {
"type": "string"
},
"assigned_by_email": {
"type": "string"
}
}
},
"UserRoleDetailMutate": {
"type": "object",
"properties": {
"user_id": {
"type": "string"
},
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"role_id": {
"type": "string"
},
"role_name": {
"type": "string"
},
"role_description": {
"type": "string"
},
"is_primary": {
"type": "boolean"
},
"assigned_at": {
"type": "string",
"format": "date-time"
},
"assigned_by": {
"type": "string"
},
"assigned_by_email": {
"type": "string"
}
}
},
"UserRoleMutate": {
"type": "object",
"properties": {
"user_id": {
"type": "string"
},
"role_id": {
"type": "string"
},
"assigned_at": {
"type": "string",
"format": "date-time"
},
"assigned_by": {
"type": "string"
},
"is_primary": {
"type": "boolean"
}
}
},
"UserSession": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"user_id": {
"type": "string"
},
"session_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
},
"token_version": {
"type": "number"
},
"device_info": {
"type": "object"
},
"ip_address": {
"type": "string"
},
"user_agent": {
"type": "string"
},
"expires_at": {
"type": "string",
"format": "date-time"
},
"refresh_expires_at": {
"type": "string",
"format": "date-time"
},
"is_active": {
"type": "boolean"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"last_activity_at": {
"type": "string",
"format": "date-time"
}
}
},
"UserSessionMutate": {
"type": "object",
"properties": {
"user_id": {
"type": "string"
},
"session_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
},
"token_version": {
"type": "number"
},
"device_info": {
"type": "object"
},
"ip_address": {
"type": "string"
},
"user_agent": {
"type": "string"
},
"expires_at": {
"type": "string",
"format": "date-time"
},
"refresh_expires_at": {
"type": "string",
"format": "date-time"
},
"is_active": {
"type": "boolean"
},
"last_activity_at": {
"type": "string",
"format": "date-time"
}
}
},
"UserSessionsDetailed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"user_id": {
"type": "string"
},
"session_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
},
"token_version": {
"type": "number"
},
"device_info": {
"type": "object"
},
"ip_address": {
"type": "string"
},
"user_agent": {
"type": "string"
},
"expires_at": {
"type": "string",
"format": "date-time"
},
"refresh_expires_at": {
"type": "string",
"format": "date-time"
},
"is_active": {
"type": "boolean"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"last_activity_at": {
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
}
}
},
"UserSessionsDetailedMutate": {
"type": "object",
"properties": {
"user_id": {
"type": "string"
},
"session_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
},
"token_version": {
"type": "number"
},
"device_info": {
"type": "object"
},
"ip_address": {
"type": "string"
},
"user_agent": {
"type": "string"
},
"expires_at": {
"type": "string",
"format": "date-time"
},
"refresh_expires_at": {
"type": "string",
"format": "date-time"
},
"is_active": {
"type": "boolean"
},
"last_activity_at": {
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},
"username": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
}
}
}
}
#!/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;
const mustache = require("mustache");
const nconf = require("nconf");
const { rimraf } = require("rimraf");
const { resolve } = require("path");
const { writeFile, readFile } = require("fs/promises");
const { existsSync, mkdirSync, readdirSync } = require("fs");
const { execSync } = require("child_process");
const { Sequelize } = require("sequelize");
const { SequelizeAuto } = require("sequelize-auto");
//==================== LOAD CONFIGS ===================/
const dotenv = require("dotenv");
dotenv.config({ path: resolve(__dirname, "../../../.env") });
nconf.argv().env({ separator: "__" });
const mode = nconf.get("mode") || "full";
//==================== LOAD CONFIGS ===================/
class DatabaseGenerator {
#message;
#modelPath;
#providerPath;
#swaggerSchemaPath;
#servicePath;
#root;
constructor() {
this.#message = "Generate Database Model";
this.#root = resolve(__dirname, "../../../src");
this.#providerPath = resolve(this.#root, "providers");
this.#swaggerSchemaPath = resolve(__dirname, "../openapi/schemas");
this.#modelPath = `${this.#root}/models`;
this.#servicePath = `${this.#root}/services/database`;
}
async executor() {
console.time(this.#message);
console.log("Checking database configuration...");
/** @type {import("../../../src/interfaces/IEnv").DatabaseConfig} */
const config = nconf.get("Database") || {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
name: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
dialect: process.env.DB_DIALECT || "postgres",
};
const forceRegen = process.argv.includes("--force") || process.argv.includes("-f");
if (mode === "full") {
if (forceRegen) {
console.log("Force regeneration enabled - will recreate all models and providers");
await rimraf(this.#modelPath);
await rimraf(this.#providerPath);
} else {
console.log("Using incremental generation - will skip existing files");
}
console.log(`Connecting to database: ${config.host}:${config.port}/${config.name}`);
try {
await this.#testConnection(config);
console.log("Database connection successful");
await this.#genModel(config, forceRegen);
} catch (error) {
console.warn("Database connection failed:", error.message);
console.log("Continuing with existing models (if any)...");
const modelsExist =
existsSync(this.#modelPath) &&
readdirSync(this.#modelPath).filter((f) => f.endsWith(".ts") && f !== "init-models.ts").length > 0;
if (!modelsExist) {
console.error(
"No existing models found and database connection failed. Please check your database configuration.",
);
process.exit(1);
}
console.log("Using existing models");
}
} else if (mode === "providers-from-template") {
await this.#genProvidersFromTemplate(forceRegen);
} else {
console.error(`Unknown mode: ${mode}. Use --mode=full or --mode=providers-from-template`);
process.exit(1);
}
// Format generated files
try {
console.log("Formatting generated files...");
// Only format the specific directories that contain generated files
const formatPaths = [];
if (existsSync(this.#modelPath)) formatPaths.push(`${this.#modelPath}/**/*.ts`);
if (existsSync(this.#providerPath)) formatPaths.push(`${this.#providerPath}/**/*.ts`);
if (formatPaths.length > 0) {
execSync(`pnpx prettier --write ${formatPaths.join(" ")} --loglevel silent`, {
stdio: "pipe",
timeout: 30000, // 30 second timeout
});
}
console.log("Code formatting completed");
} catch (error) {
console.warn("Code formatting failed or timed out, continuing...");
}
console.timeEnd(this.#message);
this.#printSummary();
}
/** @param {import("../../../src/interfaces/IEnv").DatabaseConfig} config */
async #testConnection(config) {
const testSequelize = new Sequelize({
username: config.user,
password: config.password,
database: config.name,
host: config.host,
port: config.port,
dialect: config.dialect,
logging: false,
pool: { max: 1, min: 0, acquire: 5000, idle: 1000 }, // Short timeout
});
try {
await testSequelize.authenticate();
} finally {
await testSequelize.close();
}
}
#printSummary() {
const { readdirSync } = require("fs");
try {
const modelFiles = readdirSync(this.#modelPath).filter((f) => f.endsWith(".ts") && f !== "init-models.ts");
const providerFiles = readdirSync(this.#providerPath).filter((f) => f.endsWith(".ts"));
console.log("\n" + "=".repeat(50));
console.log("DATABASE GENERATION COMPLETED");
console.log("=".repeat(50));
console.log(`Models generated: ${modelFiles.length}`);
console.log(`Providers generated: ${providerFiles.length}`);
console.log(`Models location: src/models/`);
console.log(`Providers location: src/providers/`);
console.log("=".repeat(50));
if (modelFiles.length > 0) {
console.log("\nGenerated Models:");
modelFiles.forEach((file) => console.log(` • ${file.replace(".ts", "")}`));
}
if (providerFiles.length > 0) {
console.log("\nGenerated Providers:");
providerFiles.forEach((file) => console.log(` • ${file.replace(".ts", "")}`));
}
console.log("\nNext steps:");
console.log(" 1. Review generated models and providers");
console.log(" 2. Customize providers as needed");
console.log(" 3. Update your services to use the new providers");
console.log(" 4. Test your API endpoints");
} catch (error) {
console.warn("Could not generate summary:", error.message);
}
}
/** @param {import("../../../src/interfaces/IEnv").DatabaseConfig} db @param {boolean} forceRegen */
async #genModel(db, forceRegen = false) {
console.log("🔧 Generating Sequelize models...");
if (forceRegen) {
await rimraf(this.#modelPath);
} else if (!existsSync(this.#modelPath)) {
mkdirSync(this.#modelPath, { recursive: true });
}
/** @type {import("sequelize").Sequelize} */
const sequelize = new Sequelize({
username: db.user,
password: db.password,
database: db.name,
host: db.host,
port: db.port,
dialect: db.dialect,
logging: false,
});
/** @type {import("sequelize-auto").AutoOptions} */
const options = {
dialect: db.dialect,
directory: this.#modelPath,
lang: "ts",
useDefine: false,
singularize: true,
additional: { timestamps: false },
caseFile: "p",
caseModel: "p",
caseProp: "l",
pkSuffixes: ["id", "code"],
skipTables: [
// Add common tables to skip if needed
"migrations",
"sequelize_meta",
],
views: true,
typescript: {
useDeclare: true, // Use 'declare' keyword instead of '!' for class fields
strictNullChecks: true, // Use | null for nullable fields instead of ?
},
};
const { tables } = await new SequelizeAuto(sequelize, "", "", options).run();
console.log(`📊 Generated ${Object.keys(tables).length} model files`);
// Post-process generated models to use 'declare' instead of '!' for class fields
await this.#postProcessModels();
// Generate providers for each table
await this.#genProviders(tables, db.name, forceRegen);
// Generate export file
await this.#genExport(db.name);
}
/** @param {object} tables @param {string} dbName @param {boolean} forceRegen */
async #genProviders(tables, dbName, forceRegen = false) {
// Ensure providers directory exists
mkdirSync(this.#providerPath, { recursive: true });
const providerTemplate = `import { BaseProvider } from "#templates/base/provider";
import { {{ modelName }} } from "#models/{{ modelName }}";
export class {{ providerName }} extends BaseProvider<{{ modelName }}> {
public static instance: {{ providerName }};
public static getInstance(): {{ providerName }} {
{{ providerName }}.instance ??= new {{ providerName }}();
return {{ providerName }}.instance;
}
public static get model() {
return {{ modelName }};
}
constructor() {
super("{{ tableName }}");
}
}`;
await Promise.all(
Object.keys(tables)
.filter((tableName) => tableName !== "default")
.map(async (tableName) => {
// Extract table name without schema prefix
const cleanTableName = tableName.split(".").pop();
const modelName = this.#toPascalCase(cleanTableName);
const providerName = `${modelName}Provider`;
const tableNameLower = cleanTableName.toLowerCase();
const providerFilePath = resolve(this.#providerPath, `${providerName}.ts`);
// Skip if provider already exists and not force regenerating
if (existsSync(providerFilePath) && !forceRegen) {
console.log(`⏭️ Skipping existing provider: ${providerName}`);
return;
}
if (forceRegen && existsSync(providerFilePath)) {
console.log(`🔄 Regenerating provider: ${providerName}`);
} else {
console.log(`🆕 Generating provider: ${providerName}`);
}
const providerContent = mustache.render(providerTemplate, {
modelName,
providerName,
tableName: modelName,
});
await writeFile(providerFilePath, providerContent);
}),
);
// Generate Swagger schemas
await this.#genSwaggerSchemas(tables);
}
/** Generate Swagger schemas for all models */
async #genSwaggerSchemas(tables) {
console.log("📋 Generating Swagger schemas...");
const schemas = {};
for (const [tableName, table] of Object.entries(tables)) {
if (tableName === "default") continue;
const cleanTableName = tableName.split(".").pop();
const modelName = this.#toPascalCase(cleanTableName);
// Generate schema for the model
const properties = {};
for (const [fieldName, fieldInfo] of Object.entries(table)) {
properties[fieldName] = this.#getSwaggerType(fieldInfo.type);
}
schemas[modelName] = {
type: "object",
properties,
};
// Generate mutate schema (without read-only fields)
const mutateProperties = {};
for (const [fieldName, fieldInfo] of Object.entries(table)) {
if (!["id", "created_at", "created_by", "updated_at", "updated_by"].includes(fieldName)) {
mutateProperties[fieldName] = this.#getSwaggerType(fieldInfo.type);
}
}
schemas[`${modelName}Mutate`] = {
type: "object",
properties: mutateProperties,
};
}
// Write schemas to file
mkdirSync(this.#swaggerSchemaPath, { recursive: true });
await writeFile(
resolve(this.#swaggerSchemaPath, "auto-generated-schemas.json"),
JSON.stringify(this.#sortObject(schemas), null, 2),
);
console.log(`✅ Generated ${Object.keys(schemas).length} Swagger schemas`);
}
/** Post-process generated models to use 'declare' keyword and strict null checks */
async #postProcessModels() {
console.log("🔧 Post-processing models to use 'declare' keyword and strict null checks...");
const modelFiles = readdirSync(this.#modelPath).filter((f) => f.endsWith(".ts") && f !== "init-models.ts");
for (const fileName of modelFiles) {
const filePath = resolve(this.#modelPath, fileName);
let content = await readFile(filePath, "utf-8");
// Replace class field declarations with 'declare' keyword
// Match patterns like: id!: string; and replace with: declare id: string;
content = content.replace(/^(\s+)([a-zA-Z_][a-zA-Z0-9_]*)(!)(:\s*[^;]+;)$/gm, "$1declare $2$4");
// Also ensure all class fields in the model class use 'declare'
// Match patterns like: fieldName?: type; and replace with: declare fieldName?: type | null;
// But only within the class body, not in interfaces
const classMatch = content.match(
/export class \w+ extends Model<[\s\S]*?>\s*implements [\w<>,\s]*\{\s*([\s\S]*?)\s*\}/,
);
if (classMatch) {
const classBody = classMatch[1];
// Replace optional declare fields like "declare field?: Type;" with "declare field?: Type | null;"
let updatedClassBody = classBody.replace(
/declare\s+(\w+)\?\s*:\s*([^;]+);/g,
'declare $1?: $2 | null;'
);
// Also replace optional fields without declare like "field?: Type;" with "declare field?: Type | null;"
updatedClassBody = updatedClassBody.replace(
/^(\s+)(\w+)\?\s*:\s*([^;]+);/gm,
'$1declare $2?: $3 | null;'
);
content = content.replace(classBody, updatedClassBody);
}
// Also update the interface attributes to use | null for optional fields
const interfaceMatch = content.match(/export interface \w+Attributes \{([\s\S]*?)\}/);
if (interfaceMatch) {
const interfaceBody = interfaceMatch[1];
// Replace optional fields like "field?: Type;" with "field?: Type | null;"
let updatedInterfaceBody = interfaceBody.replace(
/(\w+)\?\s*:\s*([^;]+);/g,
'$1?: $2 | null;'
);
content = content.replace(interfaceBody, updatedInterfaceBody);
}
await writeFile(filePath, content);
}
console.log(`✅ Post-processed ${modelFiles.length} model files`);
}
/** @param {string} dbName */
async #genExport(dbName) {
// The template already has initExport, so we don't need to generate a new service file
// Just ensure the models are properly exported
console.log("Database service export already exists");
}
/** @param {string} str */
#singularize(str) {
// Simple singularization rules
if (str.endsWith("ies")) return str.slice(0, -3) + "y";
if (str.endsWith("ses")) return str.slice(0, -2);
if (str.endsWith("s")) return str.slice(0, -1);
return str;
}
/** @param {string} str */
#toPascalCase(str) {
const singular = this.#singularize(str);
return singular
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
}
/** @param {boolean} forceRegen */
async #genProvidersFromTemplate(forceRegen) {
console.log("Generating providers from template...");
mkdirSync(this.#providerPath, { recursive: true });
if (forceRegen && existsSync(this.#providerPath)) {
console.log("Cleaning existing providers...");
rimraf.sync(this.#providerPath);
mkdirSync(this.#providerPath, { recursive: true });
}
const initExport = require("../../../src/services/database/sequelize/initExport");
const modelsObject = initExport.default || initExport;
const allKeys = Object.keys(modelsObject);
const modelNames = allKeys.filter((name) => name !== "sequelize" && name !== "Sequelize" && name !== "default");
console.log("initExport keys:", Object.keys(initExport));
console.log("modelsObject keys:", allKeys);
console.log("Filtered model names:", modelNames);
console.log(`Found ${modelNames.length} models to generate providers for`);
const providerTemplate = await readFile(`${__dirname}/../api/templates/provider.mustache`, "utf-8");
for (const modelName of modelNames) {
const modelInstance = modelsObject[modelName];
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 replace = {
modelUpperCase: modelName,
modelLowerCase: modelName.toLowerCase(),
hasStatus,
hasIsActive,
hasIsDeleted,
};
const providerFilePath = resolve(this.#providerPath, `${modelName}Provider.ts`);
const providerContent = mustache.render(providerTemplate, replace);
await writeFile(providerFilePath, providerContent);
console.log(`✓ Generated provider: ${modelName}Provider.ts`);
}
console.log(`✅ Generated ${modelNames.length} providers`);
}
/** Get Swagger type from Sequelize type */
#getSwaggerType(type) {
const typeString = type.toString().toLowerCase();
if (typeString.includes("boolean")) {
return { type: "boolean" };
} else if (
typeString.includes("int") ||
typeString.includes("float") ||
typeString.includes("double") ||
typeString.includes("decimal")
) {
return { type: "number" };
} else if (typeString.includes("date") || typeString.includes("time")) {
return { type: "string", format: "date-time" };
} else if (typeString.includes("json")) {
return { type: "object" };
} else {
return { type: "string" };
}
}
/** Sort object keys alphabetically */
#sortObject(obj) {
const sortedKeys = Object.keys(obj).sort();
const sortedObj = {};
for (const key of sortedKeys) {
sortedObj[key] = obj[key];
}
return sortedObj;
}
}
new DatabaseGenerator().executor().catch((err) => {
console.error("Database generation failed:", err);
process.exit(1);
});
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 source diff could not be displayed because it is too large. You can view the blob instead.
-- =========================================
-- Authentication System Database Schema
-- PostgreSQL Script for backend-template
-- =========================================
-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =========================================
-- ENUMS
-- =========================================
-- User status enum
CREATE TYPE user_status AS ENUM ('active', 'inactive', 'suspended', 'pending_verification');
-- User role enum (simplified to user, admin, and system_admin)
-- CREATE TYPE user_role AS ENUM ('user', 'admin', 'system_admin'); -- Commented out, using table instead
-- Token type enum
CREATE TYPE token_type AS ENUM ('access', 'refresh', 'password_reset');
-- =========================================
-- TABLES
-- =========================================
-- Roles table (for dynamic role management with embedded permissions)
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
permissions JSONB DEFAULT '[]'::jsonb, -- Array of permission strings
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- User roles junction table (many-to-many relationship)
CREATE TABLE user_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
assigned_by UUID REFERENCES users(id), -- Who assigned this role
is_primary BOOLEAN DEFAULT FALSE, -- Mark primary role for user
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, role_id), -- Prevent duplicate role assignments
CONSTRAINT user_primary_role_unique EXCLUDE (user_id WITH =) WHERE (is_primary = TRUE)
);
-- Users table (basic user information)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100) UNIQUE,
first_name VARCHAR(100),
last_name VARCHAR(100),
phone VARCHAR(20),
avatar_url TEXT,
status user_status DEFAULT 'pending_verification',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
-- Constraints
CONSTRAINT users_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
CONSTRAINT users_phone_format CHECK (phone IS NULL OR phone ~* '^\+?[0-9\s\-\(\)]+$'),
CONSTRAINT users_valid_status CHECK (status IN ('active', 'inactive', 'suspended', 'pending_verification'))
);
-- User authentication table (login-related information)
CREATE TABLE user_auth (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
password_hash VARCHAR(255) NOT NULL,
last_login_at TIMESTAMP WITH TIME ZONE,
password_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP WITH TIME ZONE,
-- 2FA fields
twofa_secret VARCHAR(255),
twofa_backup_codes TEXT[],
twofa_enabled BOOLEAN DEFAULT FALSE,
twofa_method VARCHAR(20) DEFAULT 'totp',
-- Password history (JSON array of recent hashes)
password_history JSONB DEFAULT '[]'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT user_auth_twofa_method CHECK (twofa_method IN ('totp', 'sms', 'email')),
UNIQUE(user_id) -- One auth record per user
);
-- User sessions table (for session management)
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token BYTEA UNIQUE NOT NULL, -- Encrypted JWT access token
refresh_token BYTEA UNIQUE NOT NULL, -- Encrypted JWT refresh token
token_version INTEGER DEFAULT 1, -- For token rotation
device_info JSONB,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
refresh_expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Password reset tokens (removed email verification)
CREATE TABLE tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type token_type NOT NULL,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Authentication audit logs (includes login attempts)
CREATE TABLE auth_audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL, -- login_attempt, login_success, logout, password_change, token_refresh, etc.
ip_address INET,
user_agent TEXT,
device_info JSONB,
success BOOLEAN DEFAULT TRUE,
failure_reason TEXT,
session_id UUID REFERENCES user_sessions(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Add to users table
ALTER TABLE users ADD COLUMN created_by UUID REFERENCES users(id);
ALTER TABLE users ADD COLUMN updated_by UUID REFERENCES users(id);
-- Add to roles table
ALTER TABLE roles ADD COLUMN created_by UUID REFERENCES users(id);
ALTER TABLE roles ADD COLUMN updated_by UUID REFERENCES users(id);
-- Add to user_roles table
ALTER TABLE permissions ADD COLUMN created_by UUID REFERENCES users(id);
ALTER TABLE permissions ADD COLUMN updated_by UUID REFERENCES users(id);
-- =========================================
-- TRIGGERS
-- =========================================
-- Updated at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply updated_at trigger to user_auth table
CREATE TRIGGER update_user_auth_updated_at BEFORE UPDATE ON user_auth
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Apply updated_at trigger to roles table
CREATE TRIGGER update_roles_updated_at BEFORE UPDATE ON roles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Apply updated_at trigger to user_roles table
CREATE TRIGGER update_user_roles_updated_at BEFORE UPDATE ON user_roles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Session activity trigger
-- Create the correct trigger function for last_activity_at
CREATE OR REPLACE FUNCTION update_last_activity_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.last_activity_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Recreate the trigger with the correct function
CREATE TRIGGER update_user_sessions_activity BEFORE UPDATE ON user_sessions
FOR EACH ROW EXECUTE FUNCTION update_last_activity_column();
-- =========================================
-- INDEXES
-- =========================================
-- User sessions indexes
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX idx_user_sessions_session_token ON user_sessions USING hash (session_token);
CREATE INDEX idx_user_sessions_refresh_token ON user_sessions USING hash (refresh_token);
CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at);
CREATE INDEX idx_user_sessions_is_active ON user_sessions(is_active);
CREATE INDEX idx_user_sessions_token_version ON user_sessions(token_version);
-- Tokens indexes
CREATE INDEX idx_tokens_user_id ON tokens(user_id);
CREATE INDEX idx_tokens_type ON tokens(type);
CREATE INDEX idx_tokens_expires_at ON tokens(expires_at);
-- Audit logs indexes
CREATE INDEX idx_auth_audit_user_id ON auth_audit_logs(user_id);
CREATE INDEX idx_auth_audit_action ON auth_audit_logs(action);
CREATE INDEX idx_auth_audit_created_at ON auth_audit_logs(created_at);
CREATE INDEX idx_auth_audit_success ON auth_audit_logs(success);
-- User auth indexes
CREATE INDEX idx_user_auth_user_id ON user_auth(user_id);
CREATE INDEX idx_user_auth_locked_until ON user_auth(locked_until);
-- =========================================
-- DEFAULT DATA
-- =========================================
-- Insert default roles with embedded permissions
INSERT INTO roles (name, description, permissions) VALUES
('user', 'Standard user with basic permissions', '["profile:read", "profile:write", "content:read"]'::jsonb),
('admin', 'Administrator with elevated permissions', '["users:read", "users:write", "users:delete", "admin:read", "admin:write", "content:read", "content:write", "content:delete", "profile:read", "profile:write"]'::jsonb),
('system_admin', 'System administrator with full access', '["users:read", "users:write", "users:delete", "admin:read", "admin:write", "content:read", "content:write", "content:delete", "profile:read", "profile:write"]'::jsonb);
-- =========================================
-- FUNCTIONS
-- =========================================
-- Function to clean up expired sessions
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM user_sessions
WHERE expires_at < CURRENT_TIMESTAMP
OR refresh_expires_at < CURRENT_TIMESTAMP;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to clean up expired tokens
CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM tokens
WHERE expires_at < CURRENT_TIMESTAMP;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to check if user is locked out
CREATE OR REPLACE FUNCTION is_user_locked(user_email VARCHAR(255))
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM users u
JOIN user_auth ua ON u.id = ua.user_id
WHERE u.email = user_email
AND ua.locked_until IS NOT NULL
AND ua.locked_until > CURRENT_TIMESTAMP
);
END;
$$ LANGUAGE plpgsql;
-- Function to invalidate all user sessions (for logout all)
CREATE OR REPLACE FUNCTION invalidate_user_sessions(user_uuid UUID)
RETURNS INTEGER AS $$
DECLARE
updated_count INTEGER;
BEGIN
UPDATE user_sessions
SET token_version = token_version + 1, is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE user_id = user_uuid AND is_active = TRUE;
GET DIAGNOSTICS updated_count = ROW_COUNT;
RETURN updated_count;
END;
$$ LANGUAGE plpgsql;
-- Function to log authentication events
CREATE OR REPLACE FUNCTION log_auth_event(
p_user_id UUID,
p_action VARCHAR(50),
p_ip_address INET,
p_user_agent TEXT,
p_device_info JSONB,
p_success BOOLEAN,
p_failure_reason TEXT DEFAULT NULL,
p_session_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
log_id UUID;
BEGIN
INSERT INTO auth_audit_logs (user_id, action, ip_address, user_agent, device_info, success, failure_reason, session_id)
VALUES (p_user_id, p_action, p_ip_address, p_user_agent, p_device_info, p_success, p_failure_reason, p_session_id)
RETURNING id INTO log_id;
RETURN log_id;
END;
$$ LANGUAGE plpgsql;
-- Function to assign role to user
CREATE OR REPLACE FUNCTION assign_user_role(
p_user_id UUID,
p_role_id UUID,
p_assigned_by UUID DEFAULT NULL,
p_is_primary BOOLEAN DEFAULT FALSE
)
RETURNS UUID AS $$
DECLARE
assignment_id UUID;
BEGIN
-- If setting as primary, unset other primary roles for this user
IF p_is_primary THEN
UPDATE user_roles SET is_primary = FALSE WHERE user_id = p_user_id;
END IF;
-- Insert or update the role assignment
INSERT INTO user_roles (user_id, role_id, assigned_by, is_primary)
VALUES (p_user_id, p_role_id, p_assigned_by, p_is_primary)
ON CONFLICT (user_id, role_id)
DO UPDATE SET
assigned_by = EXCLUDED.assigned_by,
is_primary = EXCLUDED.is_primary,
updated_at = CURRENT_TIMESTAMP
RETURNING id INTO assignment_id;
RETURN assignment_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to remove role from user
CREATE OR REPLACE FUNCTION remove_user_role(
p_user_id UUID,
p_role_id UUID
)
RETURNS BOOLEAN AS $$
DECLARE
was_primary BOOLEAN;
rows_deleted INTEGER;
BEGIN
-- Check if this was the primary role
SELECT is_primary INTO was_primary
FROM user_roles
WHERE user_id = p_user_id AND role_id = p_role_id;
-- Delete the role assignment
DELETE FROM user_roles
WHERE user_id = p_user_id AND role_id = p_role_id;
GET DIAGNOSTICS rows_deleted = ROW_COUNT;
-- If this was the primary role and user still has other roles,
-- make the oldest remaining role primary
IF was_primary AND rows_deleted > 0 THEN
UPDATE user_roles
SET is_primary = TRUE
WHERE user_id = p_user_id
AND assigned_at = (
SELECT MIN(assigned_at)
FROM user_roles
WHERE user_id = p_user_id
)
LIMIT 1;
END IF;
RETURN rows_deleted > 0;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get user roles
CREATE OR REPLACE FUNCTION get_user_roles(p_user_id UUID)
RETURNS TABLE (
role_id UUID,
role_name VARCHAR(50),
is_primary BOOLEAN,
assigned_at TIMESTAMP WITH TIME ZONE
) AS $$
BEGIN
RETURN QUERY
SELECT
r.id,
r.name,
ur.is_primary,
ur.assigned_at
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = p_user_id
ORDER BY ur.is_primary DESC, ur.assigned_at ASC;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =========================================
-- VIEWS
-- =========================================
-- Active users view
CREATE OR REPLACE VIEW active_users AS
SELECT
u.id,
u.email,
u.username,
u.first_name,
u.last_name,
primary_role.name as primary_role_name,
ua.last_login_at,
u.created_at
FROM users u
LEFT JOIN user_auth ua ON u.id = ua.user_id
LEFT JOIN user_roles ur ON u.id = ur.user_id AND ur.is_primary = TRUE
LEFT JOIN roles primary_role ON ur.role_id = primary_role.id
WHERE u.status = 'active'
AND u.deleted_at IS NULL;
-- User role details view
CREATE VIEW user_role_details AS
SELECT
u.id as user_id,
u.email,
u.username,
r.id as role_id,
r.name as role_name,
r.description as role_description,
ur.is_primary,
ur.assigned_at,
ur.assigned_by,
assigner.email as assigned_by_email
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
LEFT JOIN users assigner ON ur.assigned_by = assigner.id
WHERE u.deleted_at IS NULL
ORDER BY u.id, ur.is_primary DESC, ur.assigned_at ASC;
-- User sessions view with user info
CREATE VIEW user_sessions_detailed AS
SELECT
us.*,
u.email,
u.username,
u.first_name,
u.last_name
FROM user_sessions us
JOIN users u ON us.user_id = u.id
WHERE us.is_active = TRUE;
-- =========================================
-- RLS (Row Level Security) - Optional
-- =========================================
-- Note: RLS policies commented out as current_user_id() function is not defined
-- and this authentication system relies on application-layer security rather
-- than database-level row security.
-- Enable RLS on sensitive tables (commented out)
-- ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE tokens ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE auth_audit_logs ENABLE ROW LEVEL SECURITY;
-- RLS policies (commented out - require current_user_id() function)
-- CREATE POLICY user_sessions_policy ON user_sessions
-- FOR ALL USING (user_id = current_user_id());
-- CREATE POLICY tokens_policy ON tokens
-- FOR ALL USING (user_id = current_user_id());
-- CREATE POLICY auth_audit_policy ON auth_audit_logs
-- FOR SELECT USING (user_id = current_user_id());
-- =========================================
-- COMMENTS
-- =========================================
COMMENT ON TABLE users IS 'Basic user information table - authentication data stored in user_auth table';
COMMENT ON TABLE user_auth IS 'User authentication data including passwords, login attempts, and 2FA information';
COMMENT ON TABLE user_sessions IS 'Active user sessions for token management';
COMMENT ON TABLE tokens IS 'Password reset tokens (email verification removed)';
COMMENT ON TABLE user_roles IS 'Many-to-many user-role assignments with primary role marking';
COMMENT ON TABLE auth_audit_logs IS 'Authentication audit logs including login attempts';
COMMENT ON COLUMN user_auth.password_hash IS 'Bcrypt hashed password';
COMMENT ON COLUMN user_auth.login_attempts IS 'Failed login attempts counter';
COMMENT ON COLUMN user_auth.locked_until IS 'Account lockout timestamp';
COMMENT ON COLUMN user_auth.twofa_secret IS 'TOTP secret key';
COMMENT ON COLUMN user_auth.twofa_backup_codes IS 'Hashed backup codes for 2FA recovery';
COMMENT ON COLUMN user_auth.password_history IS 'JSON array of recent password hashes';
COMMENT ON COLUMN user_sessions.session_token IS 'Encrypted JWT access token (BYTEA)';
COMMENT ON COLUMN user_sessions.refresh_token IS 'Encrypted JWT refresh token (BYTEA)';
COMMENT ON COLUMN user_sessions.token_version IS 'Token version for rotation';
COMMENT ON COLUMN roles.permissions IS 'JSONB array of permission strings for the role';
COMMENT ON COLUMN user_roles.is_primary IS 'Marks the primary role for the user';
-- =========================================
-- FINAL NOTES
-- =========================================
/*
This schema provides a comprehensive authentication system with:
1. User management with roles and status
2. Session-based authentication with refresh tokens and rotation
3. Password reset and email verification
4. Login attempt tracking and account lockout
5. Role-based access control (RBAC)
6. Two-factor authentication (TOTP/SMS/Email)
7. Authentication audit logging
8. Password history to prevent reuse
9. Security functions and cleanup procedures
10. Row Level Security for data protection
11. Optimized indexes for performance
Enhanced Security Features:
- Token rotation to prevent replay attacks
- Comprehensive audit logging for compliance
- 2FA support for high-security accounts
- Password history enforcement
- Session invalidation on security events
Database Structure:
- 8 optimized tables (users, user_auth, roles with embedded permissions, user_roles for many-to-many relationships, user_sessions, tokens, auth_audit_logs)
- Separate index creation for better performance
- Comprehensive constraints and validations
- Row Level Security policies
To use this schema:
1. Run this script in your PostgreSQL database
2. Update your Sequelize models to match these tables
3. Implement the authentication services with audit logging
4. Configure your environment variables
5. Set up 2FA providers if needed
Security considerations:
- Always use prepared statements
- Hash passwords with bcrypt (cost factor 12+)
- Use HTTPS in production
- Implement rate limiting on auth endpoints
- Regularly clean up expired tokens and audit logs
- Monitor failed login attempts and suspicious activities
- Enable 2FA for administrative accounts
- Rotate JWT secrets regularly
*/
\ No newline at end of file
-- =========================================
-- 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,
};
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { AuthService } from "#services/authService";
import { createRateLimit } from "#middlewares/auth";
import { validateLogin } from "#middlewares/validators/auth";
export default (_express: Application) => {
return <Resource>{
/**
* @openapi
* /auth/login:
* post:
* tags: [Authentication]
* summary: User login
* description: Authenticate user with email and password
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequest'
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* $ref: '#/components/schemas/LoginResponse'
* message:
* type: object
* properties:
* vi:
* type: string
* example: "Đăng nhập thành công"
* en:
* type: string
* example: "Login successful"
* 400:
* $ref: '#/components/responses/BadRequest'
* 401:
* $ref: '#/components/responses/Unauthorized'
* 423:
* description: Account locked
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* error:
* type: object
* properties:
* code:
* type: string
* example: "LOCKED"
* message:
* type: object
* properties:
* vi:
* type: string
* example: "Tài khoản bị khóa tạm thời"
* en:
* type: string
* example: "Account is temporarily locked"
*/
post: {
middleware: [createRateLimit(15 * 60 * 1000, 5), validateLogin], // 5 attempts per 15 minutes
handler: async (req: Req, res: Res) => {
try {
const { email, password, device_info, ip_address, user_agent } = req.body;
const credentials = {
email,
password,
device_info,
ip_address: ip_address || req.ip,
user_agent: user_agent || req.get("User-Agent"),
};
const loginResult = await AuthService.login(credentials);
// Set HttpOnly cookies for enhanced security
const accessCookieOptions = AuthService.getAccessTokenCookieOptions();
const refreshCookieOptions = AuthService.getRefreshTokenCookieOptions();
res.cookie("access_token", loginResult.access_token, accessCookieOptions);
res.cookie("refresh_token", loginResult.refresh_token, refreshCookieOptions);
// Check header for token inclusion (safer than body parameter)
const includeTokens = req.headers["x-include-tokens"] === "true" || process.env.NODE_ENV === "development";
// Return optimized response with essential info only
return res.sendOk({
data: {
user: {
id: loginResult.user.id,
email: loginResult.user.email,
username: loginResult.user.username,
first_name: loginResult.user.first_name,
last_name: loginResult.user.last_name,
roles: (loginResult.user as any).roles,
permissions: (loginResult.user as any).permissions,
status: loginResult.user.status,
last_login_at: loginResult.user_auth.last_login_at,
},
session: {
id: loginResult.session.id,
expires_at: loginResult.session.expires_at,
refresh_expires_at: loginResult.session.refresh_expires_at,
},
// Include tokens only for development/Swagger testing
...(includeTokens
? {
access_token: loginResult.access_token,
refresh_token: loginResult.refresh_token,
expires_in: loginResult.expires_in,
token_type: loginResult.token_type,
}
: {}),
},
message: "Đăng nhập thành công",
message_en: "Login successful",
});
} catch (error) {
return res.error(error);
}
},
},
};
};
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { authenticate } from "#middlewares/auth";
import { AuthService } from "#services/authService";
export default (_express: Application) => {
return <Resource>{
/**
* @openapi
* /auth/logout:
* post:
* tags: [Authentication]
* summary: Logout user
* description: Logout user and invalidate current session
* security:
* - Bearer: []
* responses:
* 200:
* description: Logged out successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: object
* properties:
* vi:
* type: string
* example: "Đăng xuất thành công"
* en:
* type: string
* example: "Logged out successfully"
* 401:
* $ref: '#/components/responses/Unauthorized'
*/
post: {
middleware: [authenticate],
handler: async (req: Req, res: Res) => {
try {
// Get token from cookie or header
const token = req.cookies?.access_token || req.header("Authorization")?.split(" ")[1];
if (token) {
await AuthService.logout(token);
}
// Clear HttpOnly cookies
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/api",
});
res.clearCookie("refresh_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/api/v1.0/auth/refresh",
});
return res.sendOk({
data: null,
message: "Đăng xuất thành công",
message_en: "Logged out successfully",
});
} catch (error) {
return res.error(error);
}
},
},
};
};
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { authenticate } from "#middlewares/auth";
import { User } from "#models/User";
export default (_express: Application) => {
return <Resource>{
/**
* @openapi
* /auth/profile:
* get:
* tags: [Authentication]
* summary: Get user profile
* description: Get current authenticated user's profile information
* security:
* - Bearer: []
* responses:
* 200:
* description: Profile retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* id:
* type: string
* example: "uuid-string"
* email:
* type: string
* example: "user@example.com"
* username:
* type: string
* example: "john_doe"
* role:
* type: string
* enum: [user, admin, system_admin]
* example: "user"
* permissions:
* type: array
* items:
* type: string
* example: ["read:profile"]
* 401:
* $ref: '#/components/responses/Unauthorized'
*/
get: {
middleware: [authenticate],
handler: async (req: Req, res: Res) => {
try {
// Get user with roles and permissions
const user = await User.findByPk(req.user?.id, {
attributes: ["id", "email", "username", "first_name", "last_name", "status"],
});
if (!user) {
return res.error({ message: "User not found", status: 404 });
}
const userData = {
...user.toJSON(),
roles: req.user?.roles,
permissions: req.user?.permissions,
};
return res.sendOk({ data: userData });
} catch (error) {
return res.error(error);
}
},
},
};
};
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { AuthService } from "#services/authService";
import { validateRefreshToken } from "#middlewares/validators/auth";
export default (_express: Application) => {
return <Resource>{
/**
* @openapi
* /auth/refresh:
* post:
* tags: [Authentication]
* summary: Refresh access token
* description: Get a new access token using refresh token from cookie or body
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* refresh_token:
* type: string
* description: Refresh token (optional if sent via cookie)
* device_info:
* type: object
* description: Device information
* responses:
* 200:
* description: Token refreshed successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* $ref: '#/components/schemas/LoginResponse'
* message:
* type: object
* properties:
* vi:
* type: string
* example: "Làm mới token thành công"
* en:
* type: string
* example: "Token refreshed successfully"
* 401:
* $ref: '#/components/responses/Unauthorized'
*/
post: {
middleware: [validateRefreshToken as any],
handler: async (req: Req, res: Res) => {
try {
// Try to get refresh token from body first, then fallback to cookie
const refreshToken = req.body.refresh_token || req.cookies?.refresh_token;
const { device_info } = req.body;
const tokens = await AuthService.refreshToken(refreshToken, device_info);
// Set new tokens in HttpOnly cookies
const accessCookieOptions = AuthService.getAccessTokenCookieOptions();
const refreshCookieOptions = AuthService.getRefreshTokenCookieOptions();
res.cookie("access_token", tokens.access_token, accessCookieOptions);
res.cookie("refresh_token", tokens.refresh_token, refreshCookieOptions);
// Check header for token inclusion (safer than body parameter)
const includeTokens = req.headers["x-include-tokens"] === "true" || process.env.NODE_ENV === "development";
// Return optimized response with session info
return res.sendOk({
data: {
session: {
expires_at: new Date(Date.now() + tokens.expires_in * 1000),
refresh_expires_at: new Date(Date.now() + tokens.refresh_expires_in * 1000),
},
// Include tokens only for development/Swagger testing
...(includeTokens
? {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in: tokens.expires_in,
token_type: tokens.token_type,
}
: {}),
},
message: "Làm mới token thành công",
message_en: "Token refreshed successfully",
});
} catch (error) {
return res.error(error);
}
},
},
};
};
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { UserProvider } from "#providers/UserProvider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import verify from "#middlewares/auth";
import { validateUserCreate } from "#middlewares/validators/user";
export default (_express: Application) => {
const userProvider = new UserProvider();
return <Resource>{
/**
* @openapi
* /user:
* get:
* tags: [User]
* summary: Get all user
* description: Retrieve a list of user 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 user
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/responseGetAllData"
*/
get: {
middleware: [verify, queryModifier],
handler: async (req: Req, res: Res) => {
try {
const data = await userProvider.getAll(req.payload || {});
return res.sendOk({ data });
} catch (error) {
await userProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /user:
* post:
* tags: [User]
* summary: Create a user
* description: Create a new user record
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/UserMutate"
* responses:
* 200:
* description: User created successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/User"
*/
post: {
middleware: [verify, queryModifier, validateUserCreate],
handler: async (req: Req, res: Res) => {
try {
const data = await userProvider.create({ ...req.body, created_at: new Date(), created_by: req.user?.id || null });
return res.sendOk({ data, message: "User created successfully" });
} catch (error) {
await userProvider.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 { UserProvider } from "#providers/UserProvider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import verify from "#middlewares/auth";
import { validateId } from "#middlewares/validators";
import { validateUserUpdate } from "#middlewares/validators/user";
export default (_express: Application) => {
const userProvider = new UserProvider();
return <Resource>{
/**
* @openapi
* /user/{id}:
* get:
* tags: [User]
* summary: Get user by ID
* description: Retrieve a single user record by its ID
* security:
* - BearerAuth: []
* parameters:
* - name: id
* in: path
* required: true
* description: User ID
* schema:
* type: string
* responses:
* 200:
* description: User retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/User"
* 404:
* description: User not found
*/
get: {
middleware: [verify, queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await userProvider.getById({ id: req.params.id! });
return res.sendOk({ data });
} catch (error) {
await userProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /user/{id}:
* put:
* tags: [User]
* summary: Update user by ID
* description: Update a single user record by its ID
* security:
* - BearerAuth: []
* parameters:
* - name: id
* in: path
* required: true
* description: User ID
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/UserMutate"
* responses:
* 200:
* description: User updated successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/User"
* 404:
* description: User not found
*/
put: {
middleware: [verify, queryModifier, validateId, validateUserUpdate],
handler: async (req: Req, res: Res) => {
try {
const data = await userProvider.put(req.params.id!, { ...req.body, updated_at: new Date(), updated_by: req.user?.id || null });
return res.sendOk({ data });
} catch (error) {
await userProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /user/{id}:
* delete:
* tags: [User]
* summary: Delete user by ID
* description: Delete a single user record by its ID
* security:
* - BearerAuth: []
* parameters:
* - name: id
* in: path
* required: true
* description: User ID
* schema:
* type: string
* responses:
* 200:
* description: User deleted successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* type: boolean
* example: true
* 404:
* description: User not found
*/
delete: {
middleware: [verify, queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await userProvider.delete(req.params.id!);
return res.sendOk({ data });
} catch (error) {
await userProvider.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 { existsSync, lstatSync, readdirSync, readFileSync } from "fs";
import { Req, Res } from "#interfaces/IApi";
import { resolve } from "path";
import { authenticate } from "#middlewares/auth";
import dayjs from "dayjs";
export default (_express: Application) => {
return <Resource>{
get: {
// middleware: [authenticate],
handler: (req: Req, res: Res) => {
/*
Query:
- fromDate:
- toDate:
Date Format: dd-mm-yyyy
*/
try {
const data: Record<string, Record<string, Record<string, string>>> = {};
const dateFormat = "dd-mm-yyyy";
const baseLogPath = resolve(process.cwd(), "storage/logs");
const // Time range
from = dayjs(req.query.fromDate as string, dateFormat).isValid()
? dayjs(req.query.fromDate as string, dateFormat).startOf("day")
: dayjs().startOf("day"),
to = dayjs(req.query.toDate as string, dateFormat).isValid()
? dayjs(req.query.toDate as string, dateFormat).endOf("day")
: dayjs().endOf("day");
const recursiveCheckLogFolders = (folder: string, from: dayjs.Dayjs, to: dayjs.Dayjs) => {
// Check if path exists
if (!existsSync(folder)) return;
// Check if path is a file
if (lstatSync(folder).isFile()) {
const relativePath = folder.replace(baseLogPath + "/", "").split("/");
const fileName = relativePath.pop();
if (!fileName) return;
const // keys
key = fileName.replace(/\.((log)|(txt))$/, ""),
source = relativePath.length === 1 ? "apis" : relativePath[0] || "apis",
date = relativePath[relativePath.length - 1] || "unknown";
if (!data[source]) {
data[source] = {};
}
if (!data[source][date]) {
data[source][date] = {};
}
if (typeof data[source][date][key] === "undefined") {
data[source][date][key] = readFileSync(folder, "utf-8");
} else {
data[source][date][key] += readFileSync(folder, "utf-8");
}
return;
}
// Path is a directory
readdirSync(folder).forEach((path) => {
const pathTime = dayjs(path, "ddmmyyyy");
if (pathTime.isValid() && (pathTime.isBefore(from) || pathTime.isAfter(to))) return;
recursiveCheckLogFolders(`${folder}/${path}`, from, to);
});
};
recursiveCheckLogFolders(baseLogPath, from, to);
return res.sendOk({ data });
} catch (error) {
return res.error(error);
}
},
},
};
};
import moduleAlias from "module-alias";
import { FOLDERS } from "./constants/index.js";
import { root } from "./root";
moduleAlias.addAliases({
"#": __dirname,
...FOLDERS.reduce(
(folderAlias, folder) => Object.assign(folderAlias, { [`#${folder}`]: `${__dirname}/${folder}` }),
{},
),
});
moduleAlias();
// Set global base directory
(global as any).__baseDir = root;
const { startServer } = require("./server.js");
import type { Environment } from "./interfaces/IEnv.js";
startServer(<Environment>process.env.NODE_ENV);
import { IncomingHttpHeaders } from "http";
import { Request, Response } from "express";
import { SequelizeApiPaginatePayload } from "#services/database/sequelize/types";
import constant from "../constants/index";
import { JWTPayload } from "#services/authService";
export type ListData<T = unknown> = {
rows: T[];
count: number;
pageSize: number;
page: number;
};
export type ErrorStatusParams = {
status: number;
message: string;
message_en: string;
err?: Error | MeUError;
};
export type OkParams = {
data: unknown;
message?: string;
message_en?: string;
statusCode?: number;
};
export interface Req<T = unknown> extends Omit<Request, "user"> {
user?: JWTPayload;
payload?: SequelizeApiPaginatePayload<T>;
headers: IncomingHttpHeaders & { isadmin?: string };
[key: string]: unknown;
}
export interface Res extends Response {
sendErrorStatus?: (params: ErrorStatusParams) => void;
sendOk: (params: OkParams) => void;
sendError?: (params: { err: Error | MeUError }) => void;
error: (err: unknown) => void;
}
export interface QueryReturnType<T = unknown> {
rows: T[];
count: number;
totalPages: number;
currentPage: number;
}
// DTOs
export type ResponseDTOParams = {
data?: unknown;
message?: string;
message_en?: string;
violations?: ViolationDTO[];
};
export const ResponseDTO = ({ data, message, message_en, violations }: ResponseDTOParams) => ({
message: !message ? null : message,
message_en: !message_en ? null : message_en,
responseData: !data ? null : data,
status: violations === undefined || violations === null || violations.length == 0 ? "success" : "fail",
timeStamp: new Date().toISOString().replace(/T/, " ").replace(/\..+/, ""),
violations: !violations ? null : violations,
});
export class MeUError extends Error {
errorCode: number;
errorType: string;
errorData: unknown;
constructor(errorCode: number, errorType: string = "", errorData: unknown = null) {
// Calling parent constructor of base Error class.
super("");
// Saving class name in the property of our custom error as a shortcut.
this.name = this.constructor.name;
// Capturing stack trace, excluding constructor call from it.
// Error.captureStackTrace(this, this.constructor);
// You can use any additional properties you want.
// I'm going to use preferred HTTP status for this error types.
// `500` is the default value if not specified.
this.errorCode = errorCode || -999;
this.errorType = errorType || constant.ERROR_TYPE.API;
this.errorData = errorData;
}
}
export class ViolationDTO {
private code: number;
private message: string;
private action: unknown;
constructor(code: number, message: string, action: unknown = null) {
this.code = code;
this.message = message;
this.action = action;
}
}
export function getMessage(errorCode: number, errorType: string) {
// Catch this exception and proceed to get the default error message
let message: string;
try {
const errorConfig = constant.ErrorConfiguration[errorType as keyof typeof constant.ErrorConfiguration];
message = errorConfig[errorCode as keyof typeof errorConfig];
} catch (_ex) {
const apiConfig = constant.ErrorConfiguration[constant.ERROR_TYPE.API as keyof typeof constant.ErrorConfiguration];
message = apiConfig[errorCode as keyof typeof apiConfig];
}
return message;
}
export type Environment =
// Development
| "development"
| "Development"
| "DEVELOPMENT"
// Staging / Testing
| "staging"
| "Staging"
| "STAGING"
// Production
| "production"
| "Production"
| "PRODUCTION";
import { FindAttributeOptions, IncludeOptions, Model, WhereOptions } from "sequelize";
export type BaseQueryOptions<ModelInterface extends Model<any, any>> = {
where?: WhereOptions<ModelInterface> | null;
include?: IncludeOptions[];
attributes?: FindAttributeOptions;
};
export type PaginationOptions = {
page?: number;
pageSize?: number;
sortField?: string;
sortOrder?: string;
};
export type ExtraQueryOptions = {
raw?: boolean;
nest?: boolean;
distinct?: boolean;
subQuery?: boolean;
};
export type FindManyOptions<ModelInterface extends Model<any, any>> = BaseQueryOptions<ModelInterface> &
PaginationOptions &
ExtraQueryOptions;
// Return
export type FindManyReturnModel<ModelInterface extends Model<any, any>> = {
count: number;
rows: ModelInterface[];
page?: number;
pageSize?: number;
};
import { Language } from "../../constants/index";
export class GenericError {
message: Record<Language, string>;
type: string;
code: number;
additionalData?: unknown;
constructor(message?: Record<Language, string>, type?: string, code?: number, additionalData?: unknown) {
this.message = message || { en: "Internal server error", vi: "Lỗi máy chủ" };
this.type = type || "InternalServerError";
this.code = code || 500;
this.additionalData = additionalData;
}
// Factory method to create error instances more easily
static create(
messages: Record<Language, string>,
type: string,
code: number,
additionalData?: unknown,
): GenericError {
return new GenericError(messages, type, code, additionalData);
}
}
import { IViolations } from "../violations/IViolations";
import { IErrorHandler } from "../index";
import { BaseError, ForeignKeyConstraintError, UniqueConstraintError, ValidationError } from "sequelize";
export interface DatabaseErrorAdditionalData {
formFields?: unknown[];
errorFields?: string[];
}
/** Sequelize Error Handler */
export class DatabaseErrorHandler implements IErrorHandler {
canHandle(error: unknown): boolean {
return error instanceof ValidationError || error instanceof ForeignKeyConstraintError || error instanceof BaseError;
}
handle(error: unknown, additionalData?: DatabaseErrorAdditionalData): IViolations {
if (error instanceof UniqueConstraintError) {
return [
{
message: { en: `Unique constraint error`, vi: `Lỗi ràng buộc duy nhất` },
type: "SequelizeUniqueConstraintError",
code: 400,
additionalData: {
fields: error.errors.map((err) => ({
field: err.path,
message: { en: "Value must be unique", vi: "Giá trị phải duy nhất" },
})),
...additionalData,
},
},
];
}
if (error instanceof ValidationError) {
return [
{
message: { en: "Database validation error", vi: "Lỗi kiểm tra cơ sở dữ liệu" },
type: "SequelizeValidationError",
code: 400,
additionalData: {
fields: error.errors.map((e) => ({ field: e.path, message: { en: e.message, vi: e.message } })),
...additionalData,
},
},
];
}
if (error instanceof ForeignKeyConstraintError) {
const fields = error?.fields ? Object.keys(error?.fields) : [];
return [
{
message: { en: "Foreign key constraint error", vi: "Lỗi ràng buộc khóa ngoại" },
type: "SequelizeForeignKeyConstraintError",
code: 400,
additionalData: {
fields: fields.map((key) => ({
field: key,
message: { en: "Invalid foreign key reference", vi: "Tham chiếu khóa ngoại không hợp lệ" },
})),
...additionalData,
},
},
];
}
if (error instanceof BaseError) {
return [
{
message: { en: "Database error", vi: "Lỗi cơ sở dữ liệu" },
type: "SequelizeBaseError",
code: 400,
additionalData: {
fields: [{ field: "internal", message: { en: error.message, vi: error.message } }],
message: error.message,
stack: error.stack,
...additionalData,
},
},
];
}
throw new Error("Unhandled database error type");
}
}
import { IViolations } from "./violations/IViolations";
export interface IErrorHandler {
canHandle(error: unknown): boolean;
handle(error: unknown, additionalData?: unknown): IViolations;
}
export interface IAdditionalData {
formFields?: unknown[];
}
import { GenericErrorHandler } from "./ViolationImpl";
import { DatabaseErrorHandler } from "../handler/database-error-handler";
import { IErrorHandler } from "../";
// Interface for Violations
export interface IViolation {
message: { [key: string]: string };
type: string;
code: number;
actor?: string;
additionalData?: unknown;
}
export type IViolations = IViolation[];
// Error Processor Factory
export class ErrorProcessor {
private static instance: ErrorProcessor;
private handlers: IErrorHandler[];
private constructor() {
this.handlers = [
new DatabaseErrorHandler(), // Sequelize error handler
new GenericErrorHandler(), // Fallback handler
];
}
public static getInstance(): ErrorProcessor {
ErrorProcessor.instance ??= new ErrorProcessor();
return ErrorProcessor.instance;
}
process(error: unknown, additionalData: Record<string, unknown> = {}): IViolations {
console.error("ErrorProcessor", error);
const handler = this.handlers.find((h) => h.canHandle(error));
if (!handler) {
throw new Error("No error handler found for the given error");
}
return handler.handle(error, additionalData);
}
}
import { IViolations } from "./IViolations";
import { GenericError } from "../generic";
import { IErrorHandler } from "../";
// Fallback Handler
export class GenericErrorHandler implements IErrorHandler {
canHandle(_: unknown): boolean {
return true;
}
handle(error: GenericError | GenericError[] | null): IViolations {
// Handle single GenericError
if (error instanceof GenericError)
return [{ message: error.message, type: error.type, code: error.code, additionalData: error.additionalData }];
// Handle array of GenericErrors
if (Array.isArray(error))
return error.map((e) => ({ message: e.message, type: e.type, code: e.code, additionalData: e.additionalData }));
return [
{
message: { en: "Internal server error", vi: "Lỗi máy chủ" },
type: "InternalServerError",
code: 500,
additionalData: { fields: [], stack: error?.["stack"] || "No stack trace" },
},
];
}
}
import { Req, Res } from "#interfaces/IApi";
import { NextFunction } from "express";
import { AuthService, JWTPayload } from "../services/authService";
import { GenericError } from "#interfaces/error/generic";
import { console } from "inspector";
type UserRole = "user" | "admin" | "system_admin";
// Extend Request interface
declare module "express" {
interface Request {
user?: JWTPayload;
}
}
/**
* Authentication middleware - verifies JWT token and session
*/
export function authenticate(req: Req, res: Res, next: NextFunction): void {
try {
// Try to get token from cookie first (preferred), then fallback to Authorization header
let token: string | undefined;
// Priority 1: HttpOnly Cookie (most secure)
if (req.cookies?.access_token) {
token = req.cookies.access_token;
}
// Priority 2: Authorization Bearer header (backward compatibility)
else {
const authHeader = req.header("Authorization");
if (authHeader) {
const [bearer, bearerToken] = authHeader.split(" ");
if (bearer === "Bearer" && bearerToken) {
token = bearerToken;
}
}
}
if (!token) {
const error = new GenericError(
{ vi: "Không có token xác thực", en: "No authentication token provided" },
"UNAUTHORIZED",
401,
);
return res.error(error);
}
// Verify JWT token
const decoded = AuthService.verifyAccessToken(token);
console.log("JWT token verified successfully for user:", decoded.id);
// Validate session
AuthService.validateSession(token)
.then((user) => {
if (!user) {
console.log("Session validation returned null");
const error = new GenericError(
{ vi: "Phiên đăng nhập không hợp lệ", en: "Invalid session" },
"UNAUTHORIZED",
401,
);
return res.error(error);
}
console.log("Session validation successful, attaching user to request");
// Attach user to request
req.user = decoded;
next();
})
.catch((err) => {
console.log("Session validation threw error:", err.message);
const error = new GenericError(
{ vi: "Lỗi xác thực phiên đăng nhập", en: "Session validation error" },
"UNAUTHORIZED",
401,
);
return res.error(error);
});
} catch (err) {
const error = new GenericError({ vi: "Token không hợp lệ", en: "Invalid token" }, "UNAUTHORIZED", 401);
return res.error(error);
}
}
/**
* Authorization middleware - checks user roles/permissions
*/
export function authorize(allowedRoles: UserRole[] = []) {
return (req: Req, res: Res, next: NextFunction): void => {
if (!req.user) {
const error = new GenericError({ vi: "Chưa xác thực", en: "Not authenticated" }, "UNAUTHORIZED", 401);
return res.error(error);
}
// Check if user has required role(s)
if (allowedRoles.length > 0) {
const userRoles = req.user?.roles || [];
const hasRole = userRoles.some((r) => allowedRoles.includes(r as UserRole));
if (!hasRole) {
const error = new GenericError(
{ vi: "Không có quyền truy cập", en: "Insufficient permissions" },
"FORBIDDEN",
403,
);
return res.error(error);
}
}
next();
};
}
/**
* Admin only middleware
*/
export function requireAdmin(req: Req, res: Res, next: NextFunction): void {
return authorize(["admin", "system_admin"])(req, res, next);
}
/**
* Super admin only middleware
*/
export function requireSuperAdmin(req: Req, res: Res, next: NextFunction): void {
return authorize(["system_admin"])(req, res, next);
}
/**
* Optional authentication - doesn't fail if no token
*/
export function optionalAuth(req: Req, res: Res, next: NextFunction): void {
const authHeader = req.header("Authorization");
if (!authHeader) {
return next();
}
try {
const [bearer, token] = authHeader.split(" ");
if (bearer === "Bearer" && token) {
const decoded = AuthService.verifyAccessToken(token);
req.user = decoded;
}
} catch (error) {
// Ignore auth errors for optional auth
}
next();
}
/**
* Rate limiting helper for auth endpoints
*/
export function createRateLimit(windowMs: number = 15 * 60 * 1000, max: number = 5) {
const attempts = new Map<string, { count: number; resetTime: number }>();
return (req: Req, res: Res, next: NextFunction): void => {
const key = req.ip || req.connection.remoteAddress || "unknown";
const now = Date.now();
const windowData = attempts.get(key);
if (!windowData || now > windowData.resetTime) {
attempts.set(key, { count: 1, resetTime: now + windowMs });
return next();
}
if (windowData.count >= max) {
const error = new GenericError(
{ vi: "Quá nhiều yêu cầu, vui lòng thử lại sau", en: "Too many requests, please try again later" },
"TOO_MANY_REQUESTS",
429,
);
return res.error(error);
}
windowData.count++;
attempts.set(key, windowData);
next();
};
}
// Export utilities
export { AuthService };
export type { JWTPayload };
// Default export for backward compatibility
export default authenticate;
import type { Req, Res } from "#interfaces/IApi";
import type { NextFunction } from "express";
export function apiQueryModifier() {
return (req: Req, _: Res, next: NextFunction) => {
if (!req.query) return next();
// Transform query parameters
const queryEntries = Object.entries(req.query).filter(predicateQueryEntries);
if (queryEntries.length === 0) return next();
queryEntries.forEach(([key, value]) => {
if (key.endsWith("[]")) return transformArrayQuery(key as `${string}[]`, value);
if (key.startsWith("is_")) return transformBooleanQuery(key as `is_${string}`, value);
});
return next();
function predicateQueryEntries([key]: [string, unknown]): boolean {
return key.endsWith("[]") || key.startsWith("is_");
}
function transformArrayQuery(key: `${string}[]`, value: unknown) {
const strippedKey = key.replace(/\[\]$/, "");
delete req.query[key];
req.query[strippedKey] = (Array.isArray(value) ? value : [value]).map((v: unknown) =>
v === "null" ? null : v,
) as any;
}
function transformBooleanQuery(key: `is_${string}`, value: unknown) {
(req.query as Record<string, unknown>)[key] = value === "true" || value === "1";
}
};
}
import { Op } from "sequelize";
import { Response, NextFunction } from "express";
import { Req } from "#interfaces/IApi";
import { generateCondition, generateConditionExtra } from "#services/database/build-condition";
import { SequelizeApiPaginatePayload } from "#services/database/sequelize/types";
export default queryModifier;
export function queryModifier(req: Req, _res: Response, next: NextFunction) {
const payload: SequelizeApiPaginatePayload = {
pageSize: parseInt(req.query.pageSize?.toString() || "10"),
page: parseInt(req.query.page?.toString() || "1"),
sortField: req.query.sortField?.toString() || "",
sortOrder: req.query.sortOrder?.toString() || "",
rawFilter: req.query.filters?.toString() || "",
filters: {},
dateField: [],
};
if (payload.filters == null) payload.filters = {};
else {
const arrFilters: string[] = payload.rawFilter ? payload.rawFilter.split(",") : [];
const conditionCheckedChild: unknown[] = [];
arrFilters.forEach((element) => {
if (element.includes("|")) {
const objCondition = generateConditionExtra(element);
if (!objCondition) return;
const conditionNotOr = {
[Op.or]: objCondition,
};
conditionCheckedChild.push(conditionNotOr);
} else {
const conditionNotOr = generateCondition(element);
if (!conditionNotOr) return;
conditionCheckedChild.push(conditionNotOr);
}
});
payload.filters = { [Op.and]: conditionCheckedChild as any };
}
if (!payload.page || payload.page <= 0) payload.page = 1;
if (!payload.pageSize || payload.pageSize <= 0) payload.pageSize = 10;
if (!payload.sortField) payload.sortField = "";
if (!payload.sortOrder) payload.sortOrder = "";
req.payload = payload;
next();
}
import { NextFunction } from "express";
import {
ErrorStatusParams,
OkParams,
ViolationDTO,
getMessage,
MeUError,
ResponseDTO,
Req,
Res,
} from "#interfaces/IApi";
import LoggingService from "../services/file-system-handlers/logService";
import constants from "../constants/index";
import { ErrorProcessor } from "#interfaces/error/violations/IViolations";
export default function (_req: Req, res: Res, next: NextFunction): void {
const logger = new LoggingService();
const errorProcessor = ErrorProcessor.getInstance();
res.sendErrorStatus = function ({
status,
message = constants.DEFAULT_ERROR_MESSAGE,
message_en = constants.DEFAULT_ERROR_MESSAGE_EN,
err,
}: ErrorStatusParams) {
let SOURCE = "ERROR STATUS";
let violationMess: string = "";
const violations: ViolationDTO[] = [];
if (err instanceof MeUError) {
violationMess = getMessage(err.errorCode, err.errorType);
SOURCE += " " + err.errorType;
if (violationMess) violations.push(new ViolationDTO(err.errorCode, violationMess, err.errorData));
}
if (violations.length == 0)
violations.push(
new ViolationDTO(
constants.DEFAULT_ERROR_CODE,
getMessage(constants.DEFAULT_ERROR_CODE, constants.ERROR_TYPE.API),
),
);
logger.logErrorAsync(SOURCE, err || new Error("Unknown error"), null);
res.status(status).json(ResponseDTO({ data: null, message, message_en, violations }));
};
res.sendError = function ({ err }: { err: Error | MeUError }) {
const violationMess = err.message;
const violations: ViolationDTO[] = [];
const SOURCE = "ERROR ";
if (err instanceof MeUError && err.errorCode) {
violations.push(new ViolationDTO(err.errorCode, violationMess));
}
if (violations.length == 0) {
violations.push(
new ViolationDTO(
constants.DEFAULT_ERROR_CODE,
getMessage(constants.DEFAULT_ERROR_CODE, constants.ERROR_TYPE.API),
),
);
}
logger.logErrorAsync(SOURCE, err, null).catch((err) => console.log(err));
res.status(500).json(
ResponseDTO({
data: null,
message: constants.DEFAULT_ERROR_MESSAGE,
message_en: constants.DEFAULT_ERROR_MESSAGE_EN,
violations,
}),
);
};
res.error = function (err: unknown) {
const violations = errorProcessor.process(err);
const mainViolation = violations[0];
// Convert IViolations to ViolationDTO[]
const violationDTOs: ViolationDTO[] = violations.map(
(v) => new ViolationDTO(v.code, v.message.vi || v.message.en || "Unknown error", v.additionalData),
);
return res.status(mainViolation?.code ?? 500).json(
ResponseDTO({
data: null,
message: mainViolation?.message?.vi ?? "Đã có lỗi xảy ra",
message_en: mainViolation?.message?.en ?? "An error has occurred",
violations: violationDTOs,
}),
);
};
res.sendOk = function ({
data,
message = constants.DEFAULT_SUCCESS_MESSAGE,
message_en = constants.DEFAULT_SUCCESS_MESSAGE_EN,
statusCode,
}: OkParams) {
res.status(statusCode ?? 200).json(ResponseDTO({ data, message, message_en }));
};
next();
}
import { Req, Res } from "#interfaces/IApi";
import { NextFunction } from "express";
import { GenericError } from "#interfaces/error/generic";
import { validateRegisterUser, validateEmail } from "#services/data-handlers/validatorService";
import { validationResult, ContextRunner } from "express-validator";
export const validate = (validations: ContextRunner[]) => async (req: Req, res: Res, next: NextFunction) => {
await Promise.all(validations.map((valdation) => valdation.run(req)));
const errors = validationResult(req);
if (errors.isEmpty()) return next();
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({ onlyFirstError: true }),
});
};
export const validateEmailEntry = validate(validateEmail());
export const validateRegister = validate(validateRegisterUser());
export default validate;
import Joi from "joi";
import { validateJoi } from ".";
// Auth-specific validation schemas
export const authSchemas = {
login: Joi.object({
body: Joi.object({
email: Joi.string().email().required().messages({
"string.email": "Email không hợp lệ",
"any.required": "Email là bắt buộc",
}),
password: Joi.string().min(6).required().messages({
"string.min": "Mật khẩu phải có ít nhất 6 ký tự",
"any.required": "Mật khẩu là bắt buộc",
}),
device_info: Joi.object().optional(),
ip_address: Joi.string().optional(),
user_agent: Joi.string().optional(),
}),
params: Joi.object(),
query: Joi.object(),
}),
register: Joi.object({
body: Joi.object({
email: Joi.string().email().required().messages({
"string.email": "Email không hợp lệ",
"any.required": "Email là bắt buộc",
}),
password: Joi.string().min(6).required().messages({
"string.min": "Mật khẩu phải có ít nhất 6 ký tự",
"any.required": "Mật khẩu là bắt buộc",
}),
username: Joi.string().optional(),
first_name: Joi.string().optional(),
last_name: Joi.string().optional(),
phone: Joi.string().optional(),
}),
params: Joi.object(),
query: Joi.object(),
}),
refreshToken: Joi.object({
body: Joi.object({
refresh_token: Joi.string().optional().messages({
"string.base": "Refresh token phải là chuỗi",
}),
device_info: Joi.object().optional(),
}),
params: Joi.object(),
query: Joi.object(),
}),
changePassword: Joi.object({
body: Joi.object({
old_password: Joi.string().required().messages({
"any.required": "Mật khẩu cũ là bắt buộc",
}),
new_password: Joi.string().min(6).required().messages({
"string.min": "Mật khẩu mới phải có ít nhất 6 ký tự",
"any.required": "Mật khẩu mới là bắt buộc",
}),
}),
params: Joi.object(),
query: Joi.object(),
}),
};
// Pre-built auth validators
export const validateLogin = validateJoi(authSchemas.login);
export const validateRegister = validateJoi(authSchemas.register);
export const validateRefreshToken = validateJoi(authSchemas.refreshToken);
export const validateChangePassword = validateJoi(authSchemas.changePassword);
import { Req, Res } from "#interfaces/IApi";
import { NextFunction } from "express";
import { GenericError } from "#interfaces/error/generic";
import Joi from "joi";
export const validateJoi = (schema: Joi.ObjectSchema) => async (req: Req, res: Res, next: NextFunction) => {
try {
// Validate request body, params, and query
const { error, value } = schema.validate(
{
body: req.body,
params: req.params,
query: req.query,
},
{ abortEarly: false, stripUnknown: true },
);
if (error) {
const errors = error.details.map((detail) => ({
field: detail.path.join("."),
message: detail.message,
}));
return res.error(
new GenericError({ vi: "Dữ liệu đầu vào không hợp lệ", en: "Invalid input data" }, "VALIDATION_ERROR", 400, {
errors,
}),
);
}
// Replace request data with validated values
req.body = value.body || req.body;
req.params = value.params || req.params;
req.query = value.query || req.query;
next();
} catch (err) {
return res.error(err);
}
};
// Common validation schemas that can be used across different modules
export const commonSchemas = {
email: Joi.object({
body: Joi.object({
email: Joi.string().email().required().messages({
"string.email": "Email không hợp lệ",
"any.required": "Email là bắt buộc",
}),
}),
params: Joi.object(),
query: Joi.object(),
}),
id: Joi.object({
params: Joi.object({
id: Joi.string().required().messages({
"any.required": "ID là bắt buộc",
}),
}),
body: Joi.object(),
query: Joi.object(),
}),
// Common query parameters for getAll operations
listQuery: Joi.object({
page: Joi.number().integer().min(1).optional(),
pageSize: Joi.number().integer().min(1).max(100).optional(),
filters: Joi.string().optional(),
sortField: Joi.string().optional(),
sortOrder: Joi.string().valid("asc", "desc").optional(),
}),
};
// Pre-built common validators
export const validateEmail = validateJoi(commonSchemas.email);
export const validateId = validateJoi(commonSchemas.id);
export default validateJoi;
import Joi from "joi";
import { validateJoi } from ".";
// User-specific validation schemas
export const userSchemas = {
create: Joi.object({
body: Joi.object({
email: Joi.string().email().required().messages({
"string.email": "Email không hợp lệ",
"any.required": "Email là bắt buộc",
}),
username: Joi.string().min(3).max(50).required().messages({
"string.min": "Username phải có ít nhất 3 ký tự",
"string.max": "Username không được vượt quá 50 ký tự",
"any.required": "Username là bắt buộc",
}),
first_name: Joi.string().min(1).max(100).optional(),
last_name: Joi.string().min(1).max(100).optional(),
phone: Joi.string()
.pattern(/^(\+84|0)[3|5|7|8|9][0-9]{8}$/)
.optional()
.messages({
"string.pattern.base": "Số điện thoại không hợp lệ",
}),
}),
params: Joi.object(),
query: Joi.object(),
}),
update: Joi.object({
params: Joi.object({
id: Joi.string().required().messages({
"any.required": "ID người dùng là bắt buộc",
}),
}),
body: Joi.object({
username: Joi.string().min(3).max(50).optional().messages({
"string.min": "Username phải có ít nhất 3 ký tự",
"string.max": "Username không được vượt quá 50 ký tự",
}),
first_name: Joi.string().min(1).max(100).optional(),
last_name: Joi.string().min(1).max(100).optional(),
phone: Joi.string()
.pattern(/^(\+84|0)[3|5|7|8|9][0-9]{8}$/)
.optional()
.messages({
"string.pattern.base": "Số điện thoại không hợp lệ",
}),
}),
query: Joi.object(),
}),
};
// Pre-built user validators
export const validateUserCreate = validateJoi(userSchemas.create);
export const validateUserUpdate = validateJoi(userSchemas.update);
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
export interface ActiveUserAttributes {
id?: string;
email?: string;
username?: string;
first_name?: string;
last_name?: string;
primary_role_name?: string;
last_login_at?: Date;
created_at?: Date;
}
export type ActiveUserPk = "id";
export type ActiveUserId = ActiveUser[ActiveUserPk];
export type ActiveUserOptionalAttributes =
| "id"
| "email"
| "username"
| "first_name"
| "last_name"
| "primary_role_name"
| "last_login_at"
| "created_at";
export type ActiveUserCreationAttributes = Optional<ActiveUserAttributes, ActiveUserOptionalAttributes>;
export class ActiveUser
extends Model<ActiveUserAttributes, ActiveUserCreationAttributes>
implements ActiveUserAttributes
{
id?: string;
declare email?: string;
declare username?: string;
declare first_name?: string;
declare last_name?: string;
declare primary_role_name?: string;
declare last_login_at?: Date;
declare created_at?: Date;
static initModel(sequelize: Sequelize.Sequelize): typeof ActiveUser {
return ActiveUser.init(
{
id: {
type: DataTypes.UUID,
allowNull: true,
primaryKey: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: true,
},
username: {
type: DataTypes.STRING(100),
allowNull: true,
},
first_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
last_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
primary_role_name: {
type: DataTypes.STRING(50),
allowNull: true,
},
last_login_at: {
type: DataTypes.DATE,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
},
},
{
sequelize,
tableName: "active_users",
schema: "public",
timestamps: false,
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
import type { RolePermission, RolePermissionId } from "./RolePermission";
import type { User, UserId } from "./User";
export interface PermissionAttributes {
id: string;
name: string;
description?: string;
resource: string;
action: string;
created_at?: Date;
created_by?: string;
updated_by?: string;
}
export type PermissionPk = "id";
export type PermissionId = Permission[PermissionPk];
export type PermissionOptionalAttributes = "id" | "description" | "created_at" | "created_by" | "updated_by";
export type PermissionCreationAttributes = Optional<PermissionAttributes, PermissionOptionalAttributes>;
export class Permission
extends Model<PermissionAttributes, PermissionCreationAttributes>
implements PermissionAttributes
{
declare id: string;
declare name: string;
declare description?: string;
declare resource: string;
declare action: string;
declare created_at?: Date;
declare created_by?: string;
declare updated_by?: string;
// Permission hasMany RolePermission via permission_id
declare role_permissions: RolePermission[];
declare getRole_permissions: Sequelize.HasManyGetAssociationsMixin<RolePermission>;
declare setRole_permissions: Sequelize.HasManySetAssociationsMixin<RolePermission, RolePermissionId>;
declare addRole_permission: Sequelize.HasManyAddAssociationMixin<RolePermission, RolePermissionId>;
declare addRole_permissions: Sequelize.HasManyAddAssociationsMixin<RolePermission, RolePermissionId>;
declare createRole_permission: Sequelize.HasManyCreateAssociationMixin<RolePermission>;
declare removeRole_permission: Sequelize.HasManyRemoveAssociationMixin<RolePermission, RolePermissionId>;
declare removeRole_permissions: Sequelize.HasManyRemoveAssociationsMixin<RolePermission, RolePermissionId>;
declare hasRole_permission: Sequelize.HasManyHasAssociationMixin<RolePermission, RolePermissionId>;
declare hasRole_permissions: Sequelize.HasManyHasAssociationsMixin<RolePermission, RolePermissionId>;
declare countRole_permissions: Sequelize.HasManyCountAssociationsMixin;
// Permission belongsTo User via created_by
declare created_by_user: User;
declare getCreated_by_user: Sequelize.BelongsToGetAssociationMixin<User>;
declare setCreated_by_user: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createCreated_by_user: Sequelize.BelongsToCreateAssociationMixin<User>;
// Permission belongsTo User via updated_by
declare updated_by_user: User;
declare getUpdated_by_user: Sequelize.BelongsToGetAssociationMixin<User>;
declare setUpdated_by_user: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createUpdated_by_user: Sequelize.BelongsToCreateAssociationMixin<User>;
static initModel(sequelize: Sequelize.Sequelize): typeof Permission {
return Permission.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
unique: "permissions_name_key",
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
resource: {
type: DataTypes.STRING(100),
allowNull: false,
unique: "permissions_unique_resource_action",
},
action: {
type: DataTypes.STRING(50),
allowNull: false,
unique: "permissions_unique_resource_action",
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
created_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
},
updated_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
},
},
{
sequelize,
tableName: "permissions",
schema: "public",
timestamps: false,
indexes: [
{
name: "permissions_name_key",
unique: true,
fields: [{ name: "name" }],
},
{
name: "permissions_pkey",
unique: true,
fields: [{ name: "id" }],
},
{
name: "permissions_unique_resource_action",
unique: true,
fields: [{ name: "resource" }, { name: "action" }],
},
],
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
import type { RolePermission, RolePermissionId } from "./RolePermission";
import type { UserRole, UserRoleId } from "./UserRole";
import type { User, UserId } from "./User";
export interface RoleAttributes {
id: string;
name: string;
description?: string;
created_at?: Date;
updated_at?: Date;
created_by?: string;
updated_by?: string;
}
export type RolePk = "id";
export type RoleId = Role[RolePk];
export type RoleOptionalAttributes = "id" | "description" | "created_at" | "updated_at" | "created_by" | "updated_by";
export type RoleCreationAttributes = Optional<RoleAttributes, RoleOptionalAttributes>;
export class Role extends Model<RoleAttributes, RoleCreationAttributes> implements RoleAttributes {
declare id: string;
declare name: string;
declare description?: string;
declare created_at?: Date;
declare updated_at?: Date;
declare created_by?: string;
declare updated_by?: string;
// Role hasMany RolePermission via role_id
declare role_permissions: RolePermission[];
declare getRole_permissions: Sequelize.HasManyGetAssociationsMixin<RolePermission>;
declare setRole_permissions: Sequelize.HasManySetAssociationsMixin<RolePermission, RolePermissionId>;
declare addRole_permission: Sequelize.HasManyAddAssociationMixin<RolePermission, RolePermissionId>;
declare addRole_permissions: Sequelize.HasManyAddAssociationsMixin<RolePermission, RolePermissionId>;
declare createRole_permission: Sequelize.HasManyCreateAssociationMixin<RolePermission>;
declare removeRole_permission: Sequelize.HasManyRemoveAssociationMixin<RolePermission, RolePermissionId>;
declare removeRole_permissions: Sequelize.HasManyRemoveAssociationsMixin<RolePermission, RolePermissionId>;
declare hasRole_permission: Sequelize.HasManyHasAssociationMixin<RolePermission, RolePermissionId>;
declare hasRole_permissions: Sequelize.HasManyHasAssociationsMixin<RolePermission, RolePermissionId>;
declare countRole_permissions: Sequelize.HasManyCountAssociationsMixin;
// Role hasMany UserRole via role_id
declare user_roles: UserRole[];
declare getUser_roles: Sequelize.HasManyGetAssociationsMixin<UserRole>;
declare setUser_roles: Sequelize.HasManySetAssociationsMixin<UserRole, UserRoleId>;
declare addUser_role: Sequelize.HasManyAddAssociationMixin<UserRole, UserRoleId>;
declare addUser_roles: Sequelize.HasManyAddAssociationsMixin<UserRole, UserRoleId>;
declare createUser_role: Sequelize.HasManyCreateAssociationMixin<UserRole>;
declare removeUser_role: Sequelize.HasManyRemoveAssociationMixin<UserRole, UserRoleId>;
declare removeUser_roles: Sequelize.HasManyRemoveAssociationsMixin<UserRole, UserRoleId>;
declare hasUser_role: Sequelize.HasManyHasAssociationMixin<UserRole, UserRoleId>;
declare hasUser_roles: Sequelize.HasManyHasAssociationsMixin<UserRole, UserRoleId>;
declare countUser_roles: Sequelize.HasManyCountAssociationsMixin;
// Role belongsTo User via created_by
declare created_by_user: User;
declare getCreated_by_user: Sequelize.BelongsToGetAssociationMixin<User>;
declare setCreated_by_user: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createCreated_by_user: Sequelize.BelongsToCreateAssociationMixin<User>;
// Role belongsTo User via updated_by
declare updated_by_user: User;
declare getUpdated_by_user: Sequelize.BelongsToGetAssociationMixin<User>;
declare setUpdated_by_user: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createUpdated_by_user: Sequelize.BelongsToCreateAssociationMixin<User>;
static initModel(sequelize: Sequelize.Sequelize): typeof Role {
return Role.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
unique: "roles_name_key",
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
updated_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
created_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
},
updated_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
},
},
{
sequelize,
tableName: "roles",
schema: "public",
hasTrigger: true,
timestamps: false,
indexes: [
{
name: "roles_name_key",
unique: true,
fields: [{ name: "name" }],
},
{
name: "roles_pkey",
unique: true,
fields: [{ name: "id" }],
},
],
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
import type { Permission, PermissionId } from "./Permission";
import type { Role, RoleId } from "./Role";
export interface RolePermissionAttributes {
id: string;
permission_id: string;
created_at?: Date;
role_id?: string;
}
export type RolePermissionPk = "id";
export type RolePermissionId = RolePermission[RolePermissionPk];
export type RolePermissionOptionalAttributes = "id" | "created_at" | "role_id";
export type RolePermissionCreationAttributes = Optional<RolePermissionAttributes, RolePermissionOptionalAttributes>;
export class RolePermission
extends Model<RolePermissionAttributes, RolePermissionCreationAttributes>
implements RolePermissionAttributes
{
declare id: string;
declare permission_id: string;
declare created_at?: Date;
declare role_id?: string;
// RolePermission belongsTo Permission via permission_id
declare permission: Permission;
declare getPermission: Sequelize.BelongsToGetAssociationMixin<Permission>;
declare setPermission: Sequelize.BelongsToSetAssociationMixin<Permission, PermissionId>;
declare createPermission: Sequelize.BelongsToCreateAssociationMixin<Permission>;
// RolePermission belongsTo Role via role_id
declare role: Role;
declare getRole: Sequelize.BelongsToGetAssociationMixin<Role>;
declare setRole: Sequelize.BelongsToSetAssociationMixin<Role, RoleId>;
declare createRole: Sequelize.BelongsToCreateAssociationMixin<Role>;
static initModel(sequelize: Sequelize.Sequelize): typeof RolePermission {
return RolePermission.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
permission_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "permissions",
key: "id",
},
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
role_id: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "roles",
key: "id",
},
},
},
{
sequelize,
tableName: "role_permissions",
schema: "public",
timestamps: false,
indexes: [
{
name: "idx_role_permissions_role_id",
fields: [{ name: "role_id" }],
},
{
name: "role_permissions_pkey",
unique: true,
fields: [{ name: "id" }],
},
],
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
import type { Permission, PermissionId } from "./Permission";
import type { Role, RoleId } from "./Role";
import type { UserAuth, UserAuthCreationAttributes, UserAuthId } from "./UserAuth";
import type { UserRole, UserRoleId } from "./UserRole";
import type { UserSession, UserSessionId } from "./UserSession";
export interface UserAttributes {
id: string;
email: string;
username?: string;
first_name?: string;
last_name?: string;
phone?: string;
avatar_url?: string;
status?: "active" | "inactive" | "suspended" | "pending_verification";
created_at?: Date;
updated_at?: Date;
deleted_at?: Date;
created_by?: string;
updated_by?: string;
}
export type UserPk = "id";
export type UserId = User[UserPk];
export type UserOptionalAttributes =
| "id"
| "username"
| "first_name"
| "last_name"
| "phone"
| "avatar_url"
| "status"
| "created_at"
| "updated_at"
| "deleted_at"
| "created_by"
| "updated_by";
export type UserCreationAttributes = Optional<UserAttributes, UserOptionalAttributes>;
export class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
declare id: string;
declare email: string;
declare username?: string;
declare first_name?: string;
declare last_name?: string;
declare phone?: string;
declare avatar_url?: string;
declare status?: "active" | "inactive" | "suspended" | "pending_verification";
declare created_at?: Date;
declare updated_at?: Date;
declare deleted_at?: Date;
declare created_by?: string;
declare updated_by?: string;
// User hasMany Permission via created_by
declare permissions: Permission[];
declare getPermissions: Sequelize.HasManyGetAssociationsMixin<Permission>;
declare setPermissions: Sequelize.HasManySetAssociationsMixin<Permission, PermissionId>;
declare addPermission: Sequelize.HasManyAddAssociationMixin<Permission, PermissionId>;
declare addPermissions: Sequelize.HasManyAddAssociationsMixin<Permission, PermissionId>;
declare createPermission: Sequelize.HasManyCreateAssociationMixin<Permission>;
declare removePermission: Sequelize.HasManyRemoveAssociationMixin<Permission, PermissionId>;
declare removePermissions: Sequelize.HasManyRemoveAssociationsMixin<Permission, PermissionId>;
declare hasPermission: Sequelize.HasManyHasAssociationMixin<Permission, PermissionId>;
declare hasPermissions: Sequelize.HasManyHasAssociationsMixin<Permission, PermissionId>;
declare countPermissions: Sequelize.HasManyCountAssociationsMixin;
// User hasMany Permission via updated_by
declare updated_by_permissions: Permission[];
declare getUpdated_by_permissions: Sequelize.HasManyGetAssociationsMixin<Permission>;
declare setUpdated_by_permissions: Sequelize.HasManySetAssociationsMixin<Permission, PermissionId>;
declare addUpdated_by_permission: Sequelize.HasManyAddAssociationMixin<Permission, PermissionId>;
declare addUpdated_by_permissions: Sequelize.HasManyAddAssociationsMixin<Permission, PermissionId>;
declare createUpdated_by_permission: Sequelize.HasManyCreateAssociationMixin<Permission>;
declare removeUpdated_by_permission: Sequelize.HasManyRemoveAssociationMixin<Permission, PermissionId>;
declare removeUpdated_by_permissions: Sequelize.HasManyRemoveAssociationsMixin<Permission, PermissionId>;
declare hasUpdated_by_permission: Sequelize.HasManyHasAssociationMixin<Permission, PermissionId>;
declare hasUpdated_by_permissions: Sequelize.HasManyHasAssociationsMixin<Permission, PermissionId>;
declare countUpdated_by_permissions: Sequelize.HasManyCountAssociationsMixin;
// User hasMany Role via created_by
declare roles: Role[];
declare getRoles: Sequelize.HasManyGetAssociationsMixin<Role>;
declare setRoles: Sequelize.HasManySetAssociationsMixin<Role, RoleId>;
declare addRole: Sequelize.HasManyAddAssociationMixin<Role, RoleId>;
declare addRoles: Sequelize.HasManyAddAssociationsMixin<Role, RoleId>;
declare createRole: Sequelize.HasManyCreateAssociationMixin<Role>;
declare removeRole: Sequelize.HasManyRemoveAssociationMixin<Role, RoleId>;
declare removeRoles: Sequelize.HasManyRemoveAssociationsMixin<Role, RoleId>;
declare hasRole: Sequelize.HasManyHasAssociationMixin<Role, RoleId>;
declare hasRoles: Sequelize.HasManyHasAssociationsMixin<Role, RoleId>;
declare countRoles: Sequelize.HasManyCountAssociationsMixin;
// User hasMany Role via updated_by
declare updated_by_roles: Role[];
declare getUpdated_by_roles: Sequelize.HasManyGetAssociationsMixin<Role>;
declare setUpdated_by_roles: Sequelize.HasManySetAssociationsMixin<Role, RoleId>;
declare addUpdated_by_role: Sequelize.HasManyAddAssociationMixin<Role, RoleId>;
declare addUpdated_by_roles: Sequelize.HasManyAddAssociationsMixin<Role, RoleId>;
declare createUpdated_by_role: Sequelize.HasManyCreateAssociationMixin<Role>;
declare removeUpdated_by_role: Sequelize.HasManyRemoveAssociationMixin<Role, RoleId>;
declare removeUpdated_by_roles: Sequelize.HasManyRemoveAssociationsMixin<Role, RoleId>;
declare hasUpdated_by_role: Sequelize.HasManyHasAssociationMixin<Role, RoleId>;
declare hasUpdated_by_roles: Sequelize.HasManyHasAssociationsMixin<Role, RoleId>;
declare countUpdated_by_roles: Sequelize.HasManyCountAssociationsMixin;
// User hasOne UserAuth via user_id
declare user_auth: UserAuth;
declare getUser_auth: Sequelize.HasOneGetAssociationMixin<UserAuth>;
declare setUser_auth: Sequelize.HasOneSetAssociationMixin<UserAuth, UserAuthId>;
declare createUser_auth: Sequelize.HasOneCreateAssociationMixin<UserAuth>;
// User hasMany UserRole via assigned_by
declare user_roles: UserRole[];
declare getUser_roles: Sequelize.HasManyGetAssociationsMixin<UserRole>;
declare setUser_roles: Sequelize.HasManySetAssociationsMixin<UserRole, UserRoleId>;
declare addUser_role: Sequelize.HasManyAddAssociationMixin<UserRole, UserRoleId>;
declare addUser_roles: Sequelize.HasManyAddAssociationsMixin<UserRole, UserRoleId>;
declare createUser_role: Sequelize.HasManyCreateAssociationMixin<UserRole>;
declare removeUser_role: Sequelize.HasManyRemoveAssociationMixin<UserRole, UserRoleId>;
declare removeUser_roles: Sequelize.HasManyRemoveAssociationsMixin<UserRole, UserRoleId>;
declare hasUser_role: Sequelize.HasManyHasAssociationMixin<UserRole, UserRoleId>;
declare hasUser_roles: Sequelize.HasManyHasAssociationsMixin<UserRole, UserRoleId>;
declare countUser_roles: Sequelize.HasManyCountAssociationsMixin;
// User hasMany UserRole via user_id
declare user_user_roles: UserRole[];
declare getUser_user_roles: Sequelize.HasManyGetAssociationsMixin<UserRole>;
declare setUser_user_roles: Sequelize.HasManySetAssociationsMixin<UserRole, UserRoleId>;
declare addUser_user_role: Sequelize.HasManyAddAssociationMixin<UserRole, UserRoleId>;
declare addUser_user_roles: Sequelize.HasManyAddAssociationsMixin<UserRole, UserRoleId>;
declare createUser_user_role: Sequelize.HasManyCreateAssociationMixin<UserRole>;
declare removeUser_user_role: Sequelize.HasManyRemoveAssociationMixin<UserRole, UserRoleId>;
declare removeUser_user_roles: Sequelize.HasManyRemoveAssociationsMixin<UserRole, UserRoleId>;
declare hasUser_user_role: Sequelize.HasManyHasAssociationMixin<UserRole, UserRoleId>;
declare hasUser_user_roles: Sequelize.HasManyHasAssociationsMixin<UserRole, UserRoleId>;
declare countUser_user_roles: Sequelize.HasManyCountAssociationsMixin;
// User hasMany UserSession via user_id
declare user_sessions: UserSession[];
declare getUser_sessions: Sequelize.HasManyGetAssociationsMixin<UserSession>;
declare setUser_sessions: Sequelize.HasManySetAssociationsMixin<UserSession, UserSessionId>;
declare addUser_session: Sequelize.HasManyAddAssociationMixin<UserSession, UserSessionId>;
declare addUser_sessions: Sequelize.HasManyAddAssociationsMixin<UserSession, UserSessionId>;
declare createUser_session: Sequelize.HasManyCreateAssociationMixin<UserSession>;
declare removeUser_session: Sequelize.HasManyRemoveAssociationMixin<UserSession, UserSessionId>;
declare removeUser_sessions: Sequelize.HasManyRemoveAssociationsMixin<UserSession, UserSessionId>;
declare hasUser_session: Sequelize.HasManyHasAssociationMixin<UserSession, UserSessionId>;
declare hasUser_sessions: Sequelize.HasManyHasAssociationsMixin<UserSession, UserSessionId>;
declare countUser_sessions: Sequelize.HasManyCountAssociationsMixin;
// User belongsTo User via created_by
declare created_by_user: User;
declare getCreated_by_user: Sequelize.BelongsToGetAssociationMixin<User>;
declare setCreated_by_user: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createCreated_by_user: Sequelize.BelongsToCreateAssociationMixin<User>;
// User belongsTo User via updated_by
declare updated_by_user: User;
declare getUpdated_by_user: Sequelize.BelongsToGetAssociationMixin<User>;
declare setUpdated_by_user: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createUpdated_by_user: Sequelize.BelongsToCreateAssociationMixin<User>;
static initModel(sequelize: Sequelize.Sequelize): typeof User {
return User.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: false,
unique: "users_email_key",
},
username: {
type: DataTypes.STRING(100),
allowNull: true,
unique: "users_username_key",
},
first_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
last_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
phone: {
type: DataTypes.STRING(20),
allowNull: true,
},
avatar_url: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.ENUM("active", "inactive", "suspended", "pending_verification"),
allowNull: true,
defaultValue: "pending_verification",
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
updated_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
deleted_at: {
type: DataTypes.DATE,
allowNull: true,
},
created_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
},
updated_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
},
},
{
sequelize,
tableName: "users",
schema: "public",
hasTrigger: true,
timestamps: false,
indexes: [
{
name: "users_email_key",
unique: true,
fields: [{ name: "email" }],
},
{
name: "users_pkey",
unique: true,
fields: [{ name: "id" }],
},
{
name: "users_username_key",
unique: true,
fields: [{ name: "username" }],
},
],
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
import type { User, UserId } from "./User";
export interface UserAuthAttributes {
id: string;
user_id: string;
password_hash: string;
last_login_at?: Date;
password_changed_at?: Date;
login_attempts?: number;
locked_until?: Date;
twofa_secret?: string;
twofa_backup_codes?: string[];
twofa_enabled?: boolean;
twofa_method?: string;
password_history?: object;
created_at?: Date;
updated_at?: Date;
}
export type UserAuthPk = "id";
export type UserAuthId = UserAuth[UserAuthPk];
export type UserAuthOptionalAttributes =
| "id"
| "last_login_at"
| "password_changed_at"
| "login_attempts"
| "locked_until"
| "twofa_secret"
| "twofa_backup_codes"
| "twofa_enabled"
| "twofa_method"
| "password_history"
| "created_at"
| "updated_at";
export type UserAuthCreationAttributes = Optional<UserAuthAttributes, UserAuthOptionalAttributes>;
export class UserAuth extends Model<UserAuthAttributes, UserAuthCreationAttributes> implements UserAuthAttributes {
declare id: string;
declare user_id: string;
declare password_hash: string;
declare last_login_at?: Date;
declare password_changed_at?: Date;
declare login_attempts?: number;
declare locked_until?: Date;
declare twofa_secret?: string;
declare twofa_backup_codes?: string[];
declare twofa_enabled?: boolean;
declare twofa_method?: string;
declare password_history?: object;
declare created_at?: Date;
declare updated_at?: Date;
// UserAuth belongsTo User via user_id
declare user: User;
declare getUser: Sequelize.BelongsToGetAssociationMixin<User>;
declare setUser: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createUser: Sequelize.BelongsToCreateAssociationMixin<User>;
static initModel(sequelize: Sequelize.Sequelize): typeof UserAuth {
return UserAuth.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "users",
key: "id",
},
unique: "user_auth_user_id_key",
},
password_hash: {
type: DataTypes.STRING(255),
allowNull: false,
comment: "Bcrypt hashed password",
},
last_login_at: {
type: DataTypes.DATE,
allowNull: true,
},
password_changed_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
login_attempts: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: "Failed login attempts counter",
},
locked_until: {
type: DataTypes.DATE,
allowNull: true,
comment: "Account lockout timestamp",
},
twofa_secret: {
type: DataTypes.STRING(255),
allowNull: true,
comment: "TOTP secret key",
},
twofa_backup_codes: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: true,
comment: "Hashed backup codes for 2FA recovery",
},
twofa_enabled: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
},
twofa_method: {
type: DataTypes.STRING(20),
allowNull: true,
defaultValue: "totp",
},
password_history: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: [],
comment: "JSON array of recent password hashes",
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
updated_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
},
{
sequelize,
tableName: "user_auth",
schema: "public",
hasTrigger: true,
timestamps: false,
indexes: [
{
name: "idx_user_auth_locked_until",
fields: [{ name: "locked_until" }],
},
{
name: "idx_user_auth_user_id",
fields: [{ name: "user_id" }],
},
{
name: "user_auth_pkey",
unique: true,
fields: [{ name: "id" }],
},
{
name: "user_auth_user_id_key",
unique: true,
fields: [{ name: "user_id" }],
},
],
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
import type { Role, RoleId } from "./Role";
import type { User, UserId } from "./User";
export interface UserRoleAttributes {
id: string;
user_id: string;
role_id: string;
assigned_at?: Date;
assigned_by?: string;
is_primary?: boolean;
updated_at?: Date;
}
export type UserRolePk = "id";
export type UserRoleId = UserRole[UserRolePk];
export type UserRoleOptionalAttributes = "id" | "assigned_at" | "assigned_by" | "is_primary" | "updated_at";
export type UserRoleCreationAttributes = Optional<UserRoleAttributes, UserRoleOptionalAttributes>;
export class UserRole extends Model<UserRoleAttributes, UserRoleCreationAttributes> implements UserRoleAttributes {
declare id: string;
declare user_id: string;
declare role_id: string;
declare assigned_at?: Date;
declare assigned_by?: string;
declare is_primary?: boolean;
declare updated_at?: Date;
// UserRole belongsTo Role via role_id
declare role: Role;
declare getRole: Sequelize.BelongsToGetAssociationMixin<Role>;
declare setRole: Sequelize.BelongsToSetAssociationMixin<Role, RoleId>;
declare createRole: Sequelize.BelongsToCreateAssociationMixin<Role>;
// UserRole belongsTo User via assigned_by
declare assigned_by_user: User;
declare getAssigned_by_user: Sequelize.BelongsToGetAssociationMixin<User>;
declare setAssigned_by_user: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createAssigned_by_user: Sequelize.BelongsToCreateAssociationMixin<User>;
// UserRole belongsTo User via user_id
declare user: User;
declare getUser: Sequelize.BelongsToGetAssociationMixin<User>;
declare setUser: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createUser: Sequelize.BelongsToCreateAssociationMixin<User>;
static initModel(sequelize: Sequelize.Sequelize): typeof UserRole {
return UserRole.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "users",
key: "id",
},
unique: "user_roles_user_id_role_id_key",
},
role_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "roles",
key: "id",
},
unique: "user_roles_user_id_role_id_key",
},
assigned_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
assigned_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "users",
key: "id",
},
},
is_primary: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
},
updated_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
},
{
sequelize,
tableName: "user_roles",
schema: "public",
hasTrigger: true,
timestamps: false,
indexes: [
{
name: "idx_user_roles_is_primary",
fields: [{ name: "is_primary" }],
},
{
name: "idx_user_roles_role_id",
fields: [{ name: "role_id" }],
},
{
name: "idx_user_roles_user_id",
fields: [{ name: "user_id" }],
},
{
name: "user_primary_role_unique",
fields: [{ name: "user_id" }],
},
{
name: "user_roles_pkey",
unique: true,
fields: [{ name: "id" }],
},
{
name: "user_roles_user_id_role_id_key",
unique: true,
fields: [{ name: "user_id" }, { name: "role_id" }],
},
],
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
export interface UserRoleDetailAttributes {
user_id?: string;
email?: string;
username?: string;
role_id?: string;
role_name?: string;
role_description?: string;
is_primary?: boolean;
assigned_at?: Date;
assigned_by?: string;
assigned_by_email?: string;
}
export type UserRoleDetailOptionalAttributes =
| "user_id"
| "email"
| "username"
| "role_id"
| "role_name"
| "role_description"
| "is_primary"
| "assigned_at"
| "assigned_by"
| "assigned_by_email";
export type UserRoleDetailCreationAttributes = Optional<UserRoleDetailAttributes, UserRoleDetailOptionalAttributes>;
export class UserRoleDetail
extends Model<UserRoleDetailAttributes, UserRoleDetailCreationAttributes>
implements UserRoleDetailAttributes
{
user_id?: string;
declare email?: string;
declare username?: string;
declare role_id?: string;
declare role_name?: string;
declare role_description?: string;
declare is_primary?: boolean;
declare assigned_at?: Date;
declare assigned_by?: string;
declare assigned_by_email?: string;
static initModel(sequelize: Sequelize.Sequelize): typeof UserRoleDetail {
return UserRoleDetail.init(
{
user_id: {
type: DataTypes.UUID,
allowNull: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: true,
},
username: {
type: DataTypes.STRING(100),
allowNull: true,
},
role_id: {
type: DataTypes.UUID,
allowNull: true,
},
role_name: {
type: DataTypes.STRING(50),
allowNull: true,
},
role_description: {
type: DataTypes.TEXT,
allowNull: true,
},
is_primary: {
type: DataTypes.BOOLEAN,
allowNull: true,
},
assigned_at: {
type: DataTypes.DATE,
allowNull: true,
},
assigned_by: {
type: DataTypes.UUID,
allowNull: true,
},
assigned_by_email: {
type: DataTypes.STRING(255),
allowNull: true,
},
},
{
sequelize,
tableName: "user_role_details",
schema: "public",
timestamps: false,
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
import type { User, UserId } from "./User";
export interface UserSessionAttributes {
id: string;
user_id: string;
session_token: any;
refresh_token: any;
token_version?: number;
device_info?: object;
ip_address?: string;
user_agent?: string;
expires_at: Date;
refresh_expires_at: Date;
is_active?: boolean;
created_at?: Date;
last_activity_at?: Date;
}
export type UserSessionPk = "id";
export type UserSessionId = UserSession[UserSessionPk];
export type UserSessionOptionalAttributes =
| "id"
| "token_version"
| "device_info"
| "ip_address"
| "user_agent"
| "is_active"
| "created_at"
| "last_activity_at";
export type UserSessionCreationAttributes = Optional<UserSessionAttributes, UserSessionOptionalAttributes>;
export class UserSession
extends Model<UserSessionAttributes, UserSessionCreationAttributes>
implements UserSessionAttributes
{
declare id: string;
declare user_id: string;
declare session_token: any;
declare refresh_token: any;
declare token_version?: number;
declare device_info?: object;
declare ip_address?: string;
declare user_agent?: string;
declare expires_at: Date;
declare refresh_expires_at: Date;
declare is_active?: boolean;
declare created_at?: Date;
declare last_activity_at?: Date;
// UserSession belongsTo User via user_id
declare user: User;
declare getUser: Sequelize.BelongsToGetAssociationMixin<User>;
declare setUser: Sequelize.BelongsToSetAssociationMixin<User, UserId>;
declare createUser: Sequelize.BelongsToCreateAssociationMixin<User>;
static initModel(sequelize: Sequelize.Sequelize): typeof UserSession {
return UserSession.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
user_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "users",
key: "id",
},
},
session_token: {
type: DataTypes.BLOB,
allowNull: false,
comment: "Encrypted JWT access token (BYTEA)",
unique: "user_sessions_session_token_key",
},
refresh_token: {
type: DataTypes.BLOB,
allowNull: false,
comment: "Encrypted JWT refresh token (BYTEA)",
unique: "user_sessions_refresh_token_key",
},
token_version: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1,
comment: "Token version for rotation",
},
device_info: {
type: DataTypes.JSONB,
allowNull: true,
},
ip_address: {
type: DataTypes.INET,
allowNull: true,
},
user_agent: {
type: DataTypes.TEXT,
allowNull: true,
},
expires_at: {
type: DataTypes.DATE,
allowNull: false,
},
refresh_expires_at: {
type: DataTypes.DATE,
allowNull: false,
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
last_activity_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: Sequelize.Sequelize.literal("CURRENT_TIMESTAMP"),
},
},
{
sequelize,
tableName: "user_sessions",
schema: "public",
hasTrigger: true,
timestamps: false,
indexes: [
{
name: "idx_user_sessions_expires_at",
fields: [{ name: "expires_at" }],
},
{
name: "idx_user_sessions_is_active",
fields: [{ name: "is_active" }],
},
{
name: "idx_user_sessions_refresh_token",
fields: [{ name: "refresh_token" }],
},
{
name: "idx_user_sessions_session_token",
fields: [{ name: "session_token" }],
},
{
name: "idx_user_sessions_token_version",
fields: [{ name: "token_version" }],
},
{
name: "idx_user_sessions_user_id",
fields: [{ name: "user_id" }],
},
{
name: "user_sessions_pkey",
unique: true,
fields: [{ name: "id" }],
},
{
name: "user_sessions_refresh_token_key",
unique: true,
fields: [{ name: "refresh_token" }],
},
{
name: "user_sessions_session_token_key",
unique: true,
fields: [{ name: "session_token" }],
},
],
},
);
}
}
import * as Sequelize from "sequelize";
import { DataTypes, Model, Optional } from "sequelize";
export interface UserSessionsDetailedAttributes {
id?: string;
user_id?: string;
session_token?: any;
refresh_token?: any;
token_version?: number;
device_info?: object;
ip_address?: string;
user_agent?: string;
expires_at?: Date;
refresh_expires_at?: Date;
is_active?: boolean;
created_at?: Date;
last_activity_at?: Date;
email?: string;
username?: string;
first_name?: string;
last_name?: string;
}
export type UserSessionsDetailedPk = "id";
export type UserSessionsDetailedId = UserSessionsDetailed[UserSessionsDetailedPk];
export type UserSessionsDetailedOptionalAttributes =
| "id"
| "user_id"
| "session_token"
| "refresh_token"
| "token_version"
| "device_info"
| "ip_address"
| "user_agent"
| "expires_at"
| "refresh_expires_at"
| "is_active"
| "created_at"
| "last_activity_at"
| "email"
| "username"
| "first_name"
| "last_name";
export type UserSessionsDetailedCreationAttributes = Optional<
UserSessionsDetailedAttributes,
UserSessionsDetailedOptionalAttributes
>;
export class UserSessionsDetailed
extends Model<UserSessionsDetailedAttributes, UserSessionsDetailedCreationAttributes>
implements UserSessionsDetailedAttributes
{
id?: string;
declare user_id?: string;
declare session_token?: any;
declare refresh_token?: any;
declare token_version?: number;
declare device_info?: object;
declare ip_address?: string;
declare user_agent?: string;
declare expires_at?: Date;
declare refresh_expires_at?: Date;
declare is_active?: boolean;
declare created_at?: Date;
declare last_activity_at?: Date;
declare email?: string;
declare username?: string;
declare first_name?: string;
declare last_name?: string;
static initModel(sequelize: Sequelize.Sequelize): typeof UserSessionsDetailed {
return UserSessionsDetailed.init(
{
id: {
type: DataTypes.UUID,
allowNull: true,
primaryKey: true,
},
user_id: {
type: DataTypes.UUID,
allowNull: true,
},
session_token: {
type: DataTypes.BLOB,
allowNull: true,
},
refresh_token: {
type: DataTypes.BLOB,
allowNull: true,
},
token_version: {
type: DataTypes.INTEGER,
allowNull: true,
},
device_info: {
type: DataTypes.JSONB,
allowNull: true,
},
ip_address: {
type: DataTypes.INET,
allowNull: true,
},
user_agent: {
type: DataTypes.TEXT,
allowNull: true,
},
expires_at: {
type: DataTypes.DATE,
allowNull: true,
},
refresh_expires_at: {
type: DataTypes.DATE,
allowNull: true,
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: true,
},
last_activity_at: {
type: DataTypes.DATE,
allowNull: true,
},
email: {
type: DataTypes.STRING(255),
allowNull: true,
},
username: {
type: DataTypes.STRING(100),
allowNull: true,
},
first_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
last_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
},
{
sequelize,
tableName: "user_sessions_detailed",
schema: "public",
timestamps: false,
},
);
}
}
import type { Sequelize } from "sequelize";
import { ActiveUser as _ActiveUser } from "./ActiveUser";
import type { ActiveUserAttributes, ActiveUserCreationAttributes } from "./ActiveUser";
import { Permission as _Permission } from "./Permission";
import type { PermissionAttributes, PermissionCreationAttributes } from "./Permission";
import { RolePermission as _RolePermission } from "./RolePermission";
import type { RolePermissionAttributes, RolePermissionCreationAttributes } from "./RolePermission";
import { Role as _Role } from "./Role";
import type { RoleAttributes, RoleCreationAttributes } from "./Role";
import { UserAuth as _UserAuth } from "./UserAuth";
import type { UserAuthAttributes, UserAuthCreationAttributes } from "./UserAuth";
import { UserRoleDetail as _UserRoleDetail } from "./UserRoleDetail";
import type { UserRoleDetailAttributes, UserRoleDetailCreationAttributes } from "./UserRoleDetail";
import { UserRole as _UserRole } from "./UserRole";
import type { UserRoleAttributes, UserRoleCreationAttributes } from "./UserRole";
import { UserSession as _UserSession } from "./UserSession";
import type { UserSessionAttributes, UserSessionCreationAttributes } from "./UserSession";
import { UserSessionsDetailed as _UserSessionsDetailed } from "./UserSessionsDetailed";
import type { UserSessionsDetailedAttributes, UserSessionsDetailedCreationAttributes } from "./UserSessionsDetailed";
import { User as _User } from "./User";
import type { UserAttributes, UserCreationAttributes } from "./User";
export {
_ActiveUser as ActiveUser,
_Permission as Permission,
_RolePermission as RolePermission,
_Role as Role,
_UserAuth as UserAuth,
_UserRoleDetail as UserRoleDetail,
_UserRole as UserRole,
_UserSession as UserSession,
_UserSessionsDetailed as UserSessionsDetailed,
_User as User,
};
export type {
ActiveUserAttributes,
ActiveUserCreationAttributes,
PermissionAttributes,
PermissionCreationAttributes,
RolePermissionAttributes,
RolePermissionCreationAttributes,
RoleAttributes,
RoleCreationAttributes,
UserAuthAttributes,
UserAuthCreationAttributes,
UserRoleDetailAttributes,
UserRoleDetailCreationAttributes,
UserRoleAttributes,
UserRoleCreationAttributes,
UserSessionAttributes,
UserSessionCreationAttributes,
UserSessionsDetailedAttributes,
UserSessionsDetailedCreationAttributes,
UserAttributes,
UserCreationAttributes,
};
export function initModels(sequelize: Sequelize) {
const ActiveUser = _ActiveUser.initModel(sequelize);
const Permission = _Permission.initModel(sequelize);
const RolePermission = _RolePermission.initModel(sequelize);
const Role = _Role.initModel(sequelize);
const UserAuth = _UserAuth.initModel(sequelize);
const UserRoleDetail = _UserRoleDetail.initModel(sequelize);
const UserRole = _UserRole.initModel(sequelize);
const UserSession = _UserSession.initModel(sequelize);
const UserSessionsDetailed = _UserSessionsDetailed.initModel(sequelize);
const User = _User.initModel(sequelize);
RolePermission.belongsTo(Permission, { as: "permission", foreignKey: "permission_id" });
Permission.hasMany(RolePermission, { as: "role_permissions", foreignKey: "permission_id" });
RolePermission.belongsTo(Role, { as: "role", foreignKey: "role_id" });
Role.hasMany(RolePermission, { as: "role_permissions", foreignKey: "role_id" });
UserRole.belongsTo(Role, { as: "role", foreignKey: "role_id" });
Role.hasMany(UserRole, { as: "user_roles", foreignKey: "role_id" });
Permission.belongsTo(User, { as: "created_by_user", foreignKey: "created_by" });
User.hasMany(Permission, { as: "permissions", foreignKey: "created_by" });
Permission.belongsTo(User, { as: "updated_by_user", foreignKey: "updated_by" });
User.hasMany(Permission, { as: "updated_by_permissions", foreignKey: "updated_by" });
Role.belongsTo(User, { as: "created_by_user", foreignKey: "created_by" });
User.hasMany(Role, { as: "roles", foreignKey: "created_by" });
Role.belongsTo(User, { as: "updated_by_user", foreignKey: "updated_by" });
User.hasMany(Role, { as: "updated_by_roles", foreignKey: "updated_by" });
UserAuth.belongsTo(User, { as: "user", foreignKey: "user_id" });
User.hasOne(UserAuth, { as: "user_auth", foreignKey: "user_id" });
UserRole.belongsTo(User, { as: "assigned_by_user", foreignKey: "assigned_by" });
User.hasMany(UserRole, { as: "user_roles", foreignKey: "assigned_by" });
UserRole.belongsTo(User, { as: "user", foreignKey: "user_id" });
User.hasMany(UserRole, { as: "user_user_roles", foreignKey: "user_id" });
UserSession.belongsTo(User, { as: "user", foreignKey: "user_id" });
User.hasMany(UserSession, { as: "user_sessions", foreignKey: "user_id" });
User.belongsTo(User, { as: "created_by_user", foreignKey: "created_by" });
User.hasMany(User, { as: "users", foreignKey: "created_by" });
User.belongsTo(User, { as: "updated_by_user", foreignKey: "updated_by" });
User.hasMany(User, { as: "updated_by_users", foreignKey: "updated_by" });
return {
ActiveUser: ActiveUser,
Permission: Permission,
RolePermission: RolePermission,
Role: Role,
UserAuth: UserAuth,
UserRoleDetail: UserRoleDetail,
UserRole: UserRole,
UserSession: UserSession,
UserSessionsDetailed: UserSessionsDetailed,
User: User,
};
}
import { BaseProvider } from "#templates/base/provider";
import { ActiveUser } from "#models/ActiveUser";
export class ActiveUserProvider extends BaseProvider<ActiveUser> {
public static instance: ActiveUserProvider;
public static getInstance(): ActiveUserProvider {
ActiveUserProvider.instance ??= new ActiveUserProvider();
return ActiveUserProvider.instance;
}
public static get model() {
return ActiveUser;
}
constructor() {
super("ActiveUser");
}
}
import { BaseProvider } from "#templates/base/provider";
import { Permission } from "#models/Permission";
export class PermissionProvider extends BaseProvider<Permission> {
public static instance: PermissionProvider;
public static getInstance(): PermissionProvider {
PermissionProvider.instance ??= new PermissionProvider();
return PermissionProvider.instance;
}
public static get model() {
return Permission;
}
constructor() {
super("Permission");
}
}
import { BaseProvider } from "#templates/base/provider";
import { RolePermission } from "#models/RolePermission";
export class RolePermissionProvider extends BaseProvider<RolePermission> {
public static instance: RolePermissionProvider;
public static getInstance(): RolePermissionProvider {
RolePermissionProvider.instance ??= new RolePermissionProvider();
return RolePermissionProvider.instance;
}
public static get model() {
return RolePermission;
}
constructor() {
super("RolePermission");
}
}
import { BaseProvider } from "#templates/base/provider";
import { Role } from "#models/Role";
export class RoleProvider extends BaseProvider<Role> {
public static instance: RoleProvider;
public static getInstance(): RoleProvider {
RoleProvider.instance ??= new RoleProvider();
return RoleProvider.instance;
}
public static get model() {
return Role;
}
constructor() {
super("Role");
}
}
import { BaseProvider } from "#templates/base/provider";
import { UserAuth } from "#models/UserAuth";
export class UserAuthProvider extends BaseProvider<UserAuth> {
public static instance: UserAuthProvider;
public static getInstance(): UserAuthProvider {
UserAuthProvider.instance ??= new UserAuthProvider();
return UserAuthProvider.instance;
}
public static get model() {
return UserAuth;
}
constructor() {
super("UserAuth");
}
}
import { BaseProvider } from "#templates/base/provider";
import { User } from "#models/User";
export class UserProvider extends BaseProvider<User> {
public static instance: UserProvider;
public static getInstance(): UserProvider {
UserProvider.instance ??= new UserProvider();
return UserProvider.instance;
}
public static get model() {
return User;
}
constructor() {
super("User");
}
}
import { BaseProvider } from "#templates/base/provider";
import { UserRoleDetail } from "#models/UserRoleDetail";
export class UserRoleDetailProvider extends BaseProvider<UserRoleDetail> {
public static instance: UserRoleDetailProvider;
public static getInstance(): UserRoleDetailProvider {
UserRoleDetailProvider.instance ??= new UserRoleDetailProvider();
return UserRoleDetailProvider.instance;
}
public static get model() {
return UserRoleDetail;
}
constructor() {
super("UserRoleDetail");
}
}
import { BaseProvider } from "#templates/base/provider";
import { UserRole } from "#models/UserRole";
export class UserRoleProvider extends BaseProvider<UserRole> {
public static instance: UserRoleProvider;
public static getInstance(): UserRoleProvider {
UserRoleProvider.instance ??= new UserRoleProvider();
return UserRoleProvider.instance;
}
public static get model() {
return UserRole;
}
constructor() {
super("UserRole");
}
}
import { BaseProvider } from "#templates/base/provider";
import { UserSession } from "#models/UserSession";
export class UserSessionProvider extends BaseProvider<UserSession> {
public static instance: UserSessionProvider;
public static getInstance(): UserSessionProvider {
UserSessionProvider.instance ??= new UserSessionProvider();
return UserSessionProvider.instance;
}
public static get model() {
return UserSession;
}
constructor() {
super("UserSession");
}
}
import { BaseProvider } from "#templates/base/provider";
import { UserSessionsDetailed } from "#models/UserSessionsDetailed";
export class UserSessionsDetailedProvider extends BaseProvider<UserSessionsDetailed> {
public static instance: UserSessionsDetailedProvider;
public static getInstance(): UserSessionsDetailedProvider {
UserSessionsDetailedProvider.instance ??= new UserSessionsDetailedProvider();
return UserSessionsDetailedProvider.instance;
}
public static get model() {
return UserSessionsDetailed;
}
constructor() {
super("UserSessionsDetailed");
}
}
import { resolve } from "path";
import { config } from "dotenv";
// Load environment variables from .env file
config();
const root = resolve(__dirname, "../");
const configPath = resolve(root, ".env");
export { root, configPath };
// Express Base
import express, { Application } from "express";
import cors, { CorsOptions } from "cors";
import autoroutes from "express-automatic-routes";
import compression from "compression";
import cookieParser from "cookie-parser";
// Server-Hardware interactions
import clc from "cli-color";
import { resolve } from "path";
import { readFileSync, mkdirSync, writeFileSync } from "fs";
// Environments & Constansts
import { Environment } from "./interfaces/IEnv";
import { root } from "./root";
import constants from "./constants/index";
// Middlewares & Schedulers
import responseTemplate from "./middlewares/response";
import { apiQueryModifier } from "./middlewares/mod/api-query-modifier";
import verify from "./middlewares/auth";
// Swagger
import swaggerUI from "swagger-ui-express";
import swaggerJSDoc from "swagger-jsdoc";
// Serve Swagger to web
const serveSwagger = async (app: Application, storagePath: string) => {
// move swagger output
const doc = JSON.parse(readFileSync(`${storagePath}/swagger/swagger-output.json`, "utf8"));
app.use(constants.SWAGGER_ROUTER, swaggerUI.serve, swaggerUI.setup(doc));
};
const generateSwagger = async (storagePath: string) => {
const getSwaggerConfig = require("../lib/generator/openapi");
const config = await getSwaggerConfig();
const spec: any = swaggerJSDoc(config);
const swaggerServePath = `${storagePath}/swagger/`;
// Adjust paths to include base path
const basePath = "/api/v1.0";
const newPaths: any = {};
for (const path in spec.paths) {
newPaths[basePath + path] = spec.paths[path];
}
spec.paths = newPaths;
mkdirSync(swaggerServePath, { recursive: true });
writeFileSync(`${swaggerServePath}/swagger-output.json`, JSON.stringify(spec, null, 2));
};
const // Server functions
serverLog = (content: string) => console.log(`${clc.magenta("⚡️[server]:")} ${content}`),
initServer = async (storagePath: string, env: "development" | "staging" | "production") => {
const // Setup constant
app: Application = express(),
corsOptions = (function (env: "development" | "staging" | "production") {
if (env === "development") {
return {
origin: true,
credentials: true, // Enable cookies in CORS
} as CorsOptions;
}
const allowedOrigins = process.env.FRONTEND_URL?.split(",") || [];
return {
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true, // Enable cookies in CORS
} as CorsOptions;
})(env);
// Basic server requirements
app.use(compression());
app.use(cookieParser()); // Parse cookies
app.use(express.json({ type: "application/json" })); // Middlewares
app.use(responseTemplate as express.RequestHandler);
app.use(apiQueryModifier() as express.RequestHandler);
// Auto import controllers with express-automatic-routes
app.all("/api/*", cors(corsOptions));
autoroutes(app, { dir: resolve(__dirname, "./controllers/"), log: env == "development" });
// Public folder
app.use("/logs/*", verify as express.RequestHandler);
// Health check endpoint
app.get("/health", (req, res) => {
res.status(200).json({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
version: process.env.PROJECT_VERSION || "1.0.0",
environment: env,
});
});
// Serve swagger-output.json directly
app.get("/swagger-output.json", (req, res) => {
const filePath = resolve(storagePath, "swagger/swagger-output.json");
res.sendFile(filePath, (err) => {
if (err) {
res.status(404).json({ error: "Swagger file not found" });
}
});
});
return app;
},
startServer = async (env: Environment) => {
// Environment variables are loaded in root.ts via dotenv
const // Path
port: number = parseInt(process.env.PORT || "3000"),
serverHost: string = process.env.BACKEND_URL || "http://localhost:3000",
storagePath: string = resolve(root, "storage");
// Generate swagger output
switch (env.toLowerCase()) {
case "development":
return await startDevServer(storagePath, serverHost, port);
default:
return await startProductionServer(
storagePath,
serverHost,
port,
env.toLowerCase() as "staging" | "production",
);
}
},
startDevServer = async (storagePath: string, serverHost: string, port: number) => {
const app = await initServer(storagePath, "development");
// Generate swagger output
await generateSwagger(storagePath);
// await moveAsync(
// resolve(__dirname, "templates/swagger/swagger-output.json"),
// resolve(storagePath, "swagger/swagger-output.json"),
// { mkdirp: true },
// );
serveSwagger(app, storagePath);
serverLog(`Serving static files from ${storagePath}`);
serverLog(`App will be served at ${serverHost}`);
app.listen(port, () => {
serverLog(`Server started with worker ${clc.bgCyanBright(process.pid)}`);
});
return;
},
startProductionServer = async (
storagePath: string,
serverHost: string,
port: number,
env: "staging" | "production",
) => {
// Disable cluster for Docker production
const app = await initServer(storagePath, env);
await generateSwagger(storagePath);
app.listen(port, () => {
serverLog(`Server started`);
});
if (env == "staging" || env == "production") serveSwagger(app, storagePath);
serverLog(`Serving static files from ${clc.blueBright(storagePath)}`);
serverLog(`App will be served at ${clc.blueBright(serverHost)}\n`);
return;
};
export { startServer, serverLog };
import * as bcrypt from "bcryptjs";
import { sign, verify, SignOptions } from "jsonwebtoken";
import { QueryTypes } from "sequelize";
import sequelize from "#services/database/sequelize/service";
import { User, UserAttributes } from "../models/User";
import { UserAuth } from "../models/UserAuth";
import { Role } from "../models/Role";
import { UserRole as UserRoleModel } from "../models/UserRole";
import { RolePermission } from "../models/RolePermission";
import { Permission } from "../models/Permission";
import { UserSession } from "../models/UserSession";
import { GenericError } from "#interfaces/error/generic";
import {
isUserLocked,
incrementUserLoginAttempts,
resetUserLoginAttempts,
updateUserLastLogin,
checkAndUnlockExpiredLockout,
isSessionExpired,
isSessionRefreshExpired,
deactivateUserSession,
updateSessionActivity,
canUserChangePassword,
updateUserPasswordChanged,
isPasswordRecentlyUsed,
} from "../utils/authUtils";
enum UserStatus {
ACTIVE = "active",
INACTIVE = "inactive",
SUSPENDED = "suspended",
PENDING_VERIFICATION = "pending_verification",
}
enum UserRole {
USER = "user",
ADMIN = "admin",
SYSTEM_ADMIN = "system_admin",
}
export interface LoginCredentials {
email: string;
password: string;
device_info?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
}
export interface RegisterData {
email: string;
password: string;
username?: string;
first_name?: string;
last_name?: string;
phone?: string;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
expires_in: number;
refresh_expires_in: number;
token_type: string;
}
export interface LoginResult extends TokenPair {
user: User;
user_auth: UserAuth;
session: UserSession;
}
export interface CookieOptions {
httpOnly: boolean;
secure: boolean;
sameSite: "strict" | "lax" | "none";
maxAge: number;
path: string;
domain?: string;
}
export interface JWTPayload {
id: string;
email: string;
username?: string;
roles?: string[];
permissions?: string[];
type?: string;
iat?: number;
exp?: number;
}
export class AuthService {
private static readonly JWT_SECRET = process.env.JWT_SECRET!;
private static readonly JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
private static readonly JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m";
private static readonly JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
private static readonly BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS || "12");
private static readonly TOKEN_ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY!;
// Validate required secrets
private static validateSecrets() {
if (!this.JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
if (!this.JWT_REFRESH_SECRET) {
throw new Error("JWT_REFRESH_SECRET environment variable is required");
}
if (!this.TOKEN_ENCRYPTION_KEY) {
throw new Error("TOKEN_ENCRYPTION_KEY environment variable is required for token encryption");
}
}
/**
* Get secure cookie options for tokens
*/
static getAccessTokenCookieOptions(): CookieOptions {
const isProduction = process.env.NODE_ENV === "production";
const maxAge = this.parseTimeToSeconds(this.JWT_EXPIRES_IN) * 1000;
return {
httpOnly: true, // Prevent XSS attacks
secure: isProduction, // HTTPS only in production
sameSite: "lax", // Allow cross-subdomain requests
maxAge,
path: "/api",
...(isProduction && process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
};
}
/**
* Get secure cookie options for refresh tokens
*/
static getRefreshTokenCookieOptions(): CookieOptions {
const isProduction = process.env.NODE_ENV === "production";
const maxAge = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN) * 1000;
return {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
maxAge,
path: "/api/v1.0/auth/refresh",
...(isProduction && process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
};
}
/**
* Hash password using bcrypt
*/
static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.BCRYPT_ROUNDS);
}
/**
* Verify password against hash
*/
static async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Encrypt token using PostgreSQL pgcrypto
*/
private static async encryptToken(plainToken: string): Promise<Buffer> {
const [result] = await sequelize.query("SELECT encrypt_token(:token, :key) as encrypted", {
replacements: {
token: plainToken,
key: this.TOKEN_ENCRYPTION_KEY,
},
type: QueryTypes.SELECT,
});
return (result as any).encrypted;
}
/**
* Decrypt token using PostgreSQL pgcrypto
*/
private static async decryptToken(encryptedToken: Buffer): Promise<string> {
const [result] = await sequelize.query("SELECT decrypt_token(:encrypted_token, :key) as decrypted", {
replacements: {
encrypted_token: encryptedToken,
key: this.TOKEN_ENCRYPTION_KEY,
},
type: QueryTypes.SELECT,
});
return (result as any).decrypted;
}
/**
* Generate JWT access token
*/
static generateAccessToken(user: User): string {
this.validateSecrets();
const primaryUserRole = (user as any).user_user_roles?.find((ur: any) => ur.is_primary);
const payload: JWTPayload = {
id: user.id,
email: user.email,
roles: (user as any).roles || [], // All role names
permissions: (user as any).permissions || [], // Permissions collected from all roles
};
if (user.username) {
payload.username = user.username;
}
return sign(payload, this.JWT_SECRET as any, {
expiresIn: this.JWT_EXPIRES_IN as any,
issuer: "backend-template",
audience: "api-users",
});
}
/**
* Generate JWT refresh token
*/
static generateRefreshToken(user: User): string {
this.validateSecrets();
const payload = {
id: user.id,
email: user.email,
roles: (user as any).roles || [],
permissions: (user as any).permissions || [],
type: "refresh",
};
const options: SignOptions = {
expiresIn: this.JWT_REFRESH_EXPIRES_IN as any,
issuer: "backend-template",
audience: "api-users",
};
return sign(payload, this.JWT_REFRESH_SECRET as any, options);
}
/**
* Verify JWT access token
*/
static verifyAccessToken(token: string): JWTPayload {
this.validateSecrets();
try {
const decoded = verify(token, this.JWT_SECRET, {
issuer: "backend-template",
audience: "api-users",
}) as unknown as JWTPayload;
return decoded;
} catch (error) {
throw new GenericError({ vi: "Token không hợp lệ", en: "Invalid token" }, "UNAUTHORIZED", 401);
}
}
/**
* Verify JWT refresh token
*/
static verifyRefreshToken(token: string): JWTPayload {
this.validateSecrets();
try {
const decoded = verify(token, this.JWT_REFRESH_SECRET, {
issuer: "backend-template",
audience: "api-users",
}) as unknown as JWTPayload;
if (decoded.type !== "refresh") {
throw new Error("Invalid token type");
}
return decoded;
} catch (error) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
}
/**
* Register new user
*/
static async register(data: RegisterData): Promise<User> {
return sequelize.transaction(async (transaction) => {
const { email, password, username, first_name, last_name, phone } = data;
// Check if user already exists
const existingUser = await User.findOne({
where: { email },
paranoid: false, // Include soft deleted users
transaction,
});
if (existingUser) {
if (existingUser.deleted_at) {
// Reactivate soft deleted user
await existingUser.restore({ transaction });
// Update UserAuth for reactivated user
const existingUserAuth = await UserAuth.findOne({
where: { user_id: existingUser.id },
transaction,
});
if (existingUserAuth) {
existingUserAuth.password_hash = await this.hashPassword(password);
await existingUserAuth.save({ transaction });
}
existingUser.status = "pending_verification";
return existingUser.save({ transaction });
} else {
throw new GenericError({ vi: "Người dùng đã tồn tại", en: "User already exists" }, "CONFLICT", 409);
}
}
// Hash password
const password_hash = await this.hashPassword(password);
// Get default user role
const defaultRole = await Role.findOne({ where: { name: "user" } });
if (!defaultRole) {
throw new Error("Default user role not found");
}
// Create user
const userData: Partial<UserAttributes> = {
email,
status: UserStatus.PENDING_VERIFICATION,
};
if (username) userData.username = username;
if (first_name) userData.first_name = first_name;
if (last_name) userData.last_name = last_name;
if (phone) userData.phone = phone;
const user = await User.create(userData as any, { transaction });
// Create UserAuth
await UserAuth.create(
{
user_id: user.id,
password_hash,
},
{ transaction },
);
// Assign default role
await UserRoleModel.create(
{
user_id: user.id,
role_id: defaultRole.id,
is_primary: true,
},
{ transaction },
);
return user;
});
}
/**
* Authenticate user login
*/
static async login(credentials: LoginCredentials): Promise<LoginResult> {
const { email, password, device_info, ip_address, user_agent } = credentials;
// Find user with auth data
const userInstance = await User.findOne({
where: { email },
include: [{ model: UserAuth, as: "user_auth" }],
});
if (!userInstance) {
throw new GenericError(
{ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" },
"UNAUTHORIZED",
401,
);
}
// Get plain data values
const user = userInstance.dataValues as User;
let userAuth = userInstance.user_auth;
// If UserAuth doesn't exist, this is an error (every user should have UserAuth)
if (!userAuth) {
throw new GenericError(
{ vi: "Tài khoản chưa được thiết lập đầy đủ", en: "Account not fully set up" },
"UNAUTHORIZED",
401,
);
}
// Fetch user roles with permissions
const userRoles = await UserRoleModel.findAll({
where: { user_id: user.id },
include: [
{
model: Role,
as: "role",
include: [
{
model: RolePermission,
as: "role_permissions",
include: [
{
model: Permission,
as: "permission",
attributes: ["name"],
},
],
},
],
},
],
});
// Collect permissions and roles from all roles (unique)
const permissionsSet = new Set<string>();
const rolesSet = new Set<string>();
if (userRoles) {
for (const userRole of userRoles) {
if (userRole.role) {
rolesSet.add(userRole.role.name);
if (userRole.role.role_permissions) {
for (const rp of userRole.role.role_permissions) {
if (rp.permission && rp.permission.name) {
permissionsSet.add(rp.permission.name);
}
}
}
}
}
}
(user as any).permissions = Array.from(permissionsSet);
(user as any).roles = Array.from(rolesSet);
// Check if account lockout has expired and unlock if needed
if (userAuth) await checkAndUnlockExpiredLockout(userAuth);
// Check if account is locked
if (userAuth && isUserLocked(userAuth)) {
throw new GenericError({ vi: "Tài khoản bị khóa tạm thời", en: "Account is temporarily locked" }, "LOCKED", 423);
}
// Check if account is active
if (user.status !== UserStatus.ACTIVE) {
throw new GenericError({ vi: "Tài khoản chưa được kích hoạt", en: "Account is not active" }, "FORBIDDEN", 403);
}
// Check if password_hash exists
if (!userAuth?.password_hash) {
throw new GenericError(
{ vi: "Tài khoản chưa được thiết lập mật khẩu", en: "Account password not set" },
"UNAUTHORIZED",
401,
);
}
// Verify password
const isValidPassword = await this.verifyPassword(password, userAuth.password_hash);
if (!isValidPassword) {
if (userAuth) await incrementUserLoginAttempts(userAuth);
throw new GenericError(
{ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" },
"UNAUTHORIZED",
401,
);
}
// Reset login attempts on successful login
if (userAuth) await resetUserLoginAttempts(userAuth);
if (userAuth) await updateUserLastLogin(userAuth);
// Generate tokens
const access_token = this.generateAccessToken(user);
const refresh_token = this.generateRefreshToken(user);
// Encrypt tokens before storing
const encrypted_access_token = await this.encryptToken(access_token);
const encrypted_refresh_token = await this.encryptToken(refresh_token);
// Calculate expiration times
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
// Create session with encrypted tokens
const sessionData: any = {
user_id: user.id,
session_token: encrypted_access_token,
refresh_token: encrypted_refresh_token,
device_info: device_info || {},
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
is_active: true,
permissions: (user as any).permissions || [],
roles: (user as any).roles || [],
};
if (ip_address) sessionData.ip_address = ip_address;
if (user_agent) sessionData.user_agent = user_agent;
await UserSession.create(sessionData as any);
// Get the created session
const createdSession = await UserSession.findOne({
where: { session_token: encrypted_access_token },
});
return {
access_token,
refresh_token,
expires_in,
refresh_expires_in,
token_type: "Bearer",
user,
user_auth: userAuth.dataValues as UserAuth,
session: createdSession?.dataValues as UserSession,
};
}
/**
* Refresh access token
*/
static async refreshToken(refreshToken: string, device_info?: Record<string, unknown>): Promise<TokenPair> {
// Find active session by decrypting stored refresh tokens
const sessions = await sequelize.query(
`
SELECT us.*
FROM user_sessions us
WHERE us.is_active = true
AND us.refresh_expires_at > NOW()
`,
{
type: QueryTypes.SELECT,
},
);
// Find matching session by decrypting refresh tokens
let matchingSession: any = null;
for (const session of sessions as any[]) {
try {
const decryptedRefreshToken = await this.decryptToken(session.refresh_token);
if (decryptedRefreshToken === refreshToken) {
matchingSession = session;
break;
}
} catch (error) {
// Skip invalid encrypted tokens
continue;
}
}
if (!matchingSession) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
// Verify refresh token JWT
const decoded = this.verifyRefreshToken(refreshToken);
// Create user object from token data
const user = {
id: decoded.id,
email: decoded.email,
roles: decoded.roles || [],
permissions: decoded.permissions || [],
} as any;
// Generate new tokens
const access_token = this.generateAccessToken(user);
const new_refresh_token = this.generateRefreshToken(user);
// Encrypt new tokens
const encrypted_access_token = await this.encryptToken(access_token);
const encrypted_refresh_token = await this.encryptToken(new_refresh_token);
// Calculate expiration times
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
// Update session with encrypted tokens
await sequelize.query(
`
UPDATE user_sessions
SET session_token = :access_token,
refresh_token = :refresh_token,
expires_at = :expires_at,
refresh_expires_at = :refresh_expires_at,
device_info = :device_info,
last_activity_at = NOW()
WHERE id = :session_id
`,
{
replacements: {
access_token: encrypted_access_token,
refresh_token: encrypted_refresh_token,
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
device_info: device_info && Object.keys(device_info).length > 0 ? device_info : null,
session_id: matchingSession.id,
},
type: QueryTypes.UPDATE,
},
);
return {
access_token,
refresh_token: new_refresh_token,
expires_in,
refresh_expires_in,
token_type: "Bearer",
};
}
/**
* Logout user (deactivate session)
*/
static async logout(accessToken: string): Promise<void> {
// Find session by decrypting access tokens
const sessions = await sequelize.query(
`
SELECT id, session_token
FROM user_sessions
WHERE is_active = true
`,
{
type: QueryTypes.SELECT,
},
);
// Find matching session
let sessionId: string | null = null;
for (const session of sessions as any[]) {
try {
const decryptedToken = await this.decryptToken(session.session_token);
if (decryptedToken === accessToken) {
sessionId = session.id;
break;
}
} catch (error) {
continue;
}
}
if (sessionId) {
await sequelize.query("UPDATE user_sessions SET is_active = false WHERE id = :session_id", {
replacements: { session_id: sessionId },
type: QueryTypes.UPDATE,
});
}
}
/**
* Logout from all devices
*/
static async logoutAll(userId: string): Promise<void> {
await UserSession.update(
{ is_active: false },
{
where: {
user_id: userId,
is_active: true,
},
},
);
}
/**
* Get user by ID with sessions
*/
static async getUserWithSessions(userId: string): Promise<User | null> {
return User.findByPk(userId, {
include: [
{
model: UserSession,
as: "sessions",
where: { is_active: true },
required: false,
},
],
});
}
/**
* Validate session token
*/
static async validateSession(sessionToken: string): Promise<User | null> {
let sessions: any[];
try {
// Find sessions and decrypt tokens to match
sessions = await sequelize.query(
`
SELECT us.*, u.email, u.username, u.first_name, u.last_name, u.status
FROM user_sessions us
JOIN users u ON us.user_id = u.id
WHERE us.is_active = true
AND us.expires_at > NOW()
`,
{
type: QueryTypes.SELECT,
},
);
} catch (queryError) {
throw queryError;
}
// Find matching session by decrypting access tokens
let matchingSession: any = null;
let decryptedToken: string | null = null;
for (const session of sessions as any[]) {
try {
const decryptedAccessToken = await this.decryptToken(session.session_token);
if (decryptedAccessToken === sessionToken) {
matchingSession = session;
decryptedToken = decryptedAccessToken;
break;
}
} catch (error) {
// Skip invalid encrypted tokens
continue;
}
}
if (!matchingSession || !decryptedToken) {
return null;
}
// Verify JWT token
try {
this.verifyAccessToken(decryptedToken);
} catch (error) {
return null;
}
// Create user object
const user = {
id: matchingSession.user_id,
email: matchingSession.email,
username: matchingSession.username,
first_name: matchingSession.first_name,
last_name: matchingSession.last_name,
status: matchingSession.status,
} as any;
// Update last activity
try {
await sequelize.query("UPDATE user_sessions SET last_activity_at = NOW() WHERE id = :session_id", {
replacements: { session_id: matchingSession.id },
type: QueryTypes.UPDATE,
});
} catch (updateError) {
// Don't fail validation just because we can't update activity
}
return user;
}
/**
* Change user password
*/
static async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = await User.findByPk(userId, {
include: [{ model: UserAuth, as: "user_auth" }],
});
if (!user) {
throw new GenericError({ vi: "Người dùng không tồn tại", en: "User not found" }, "NOT_FOUND", 404);
}
const userAuth = user.user_auth;
if (!userAuth) {
throw new GenericError(
{ vi: "Dữ liệu xác thực không tồn tại", en: "Authentication data not found" },
"NOT_FOUND",
404,
);
}
// Check if password can be changed (not too recently)
if (!canUserChangePassword(userAuth)) {
throw new GenericError(
{ vi: "Mật khẩu chỉ có thể thay đổi sau 24 giờ", en: "Password can only be changed after 24 hours" },
"FORBIDDEN",
403,
);
}
// Verify old password
const isValidOldPassword = await this.verifyPassword(oldPassword, userAuth.password_hash);
if (!isValidOldPassword) {
throw new GenericError({ vi: "Mật khẩu cũ không đúng", en: "Invalid old password" }, "UNAUTHORIZED", 401);
}
// Hash new password
const newPasswordHash = await this.hashPassword(newPassword);
// Check if password was recently used
const passwordHistory = (userAuth.password_history as string[]) || [];
if (isPasswordRecentlyUsed(userAuth, passwordHistory, newPasswordHash)) {
throw new GenericError(
{ vi: "Mật khẩu đã được sử dụng gần đây", en: "Password was recently used" },
"FORBIDDEN",
403,
);
}
// Update password history
passwordHistory.unshift(userAuth.password_hash); // Add old hash
if (passwordHistory.length > 5) passwordHistory.pop(); // Keep last 5
userAuth.password_history = passwordHistory;
// Update password
userAuth.password_hash = newPasswordHash;
await updateUserPasswordChanged(userAuth);
// Logout from all other sessions
await this.logoutAll(userId);
}
/**
* Parse time string to seconds (e.g., '15m' -> 900)
*/
private static parseTimeToSeconds(timeStr: string | undefined): number {
if (!timeStr) return 900; // 15 minutes default
const regex = /^(\d+)([smhd])$/;
const match = timeStr.match(regex);
if (!match) {
throw new Error(`Invalid time format: ${timeStr || "undefined"}`);
}
const value = parseInt(match[1] || "0");
const unit = match[2] || "m";
switch (unit) {
case "s":
return value;
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
throw new Error(`Invalid time unit: ${unit}`);
}
}
}
import { existsSync } from "fs";
import { fork } from "child_process";
export async function wrapCompression(
/** Path to the compression module */
modulePath: string,
/** Size to be compressed, between: desktop | mobile | tablet */
compressSize: string,
/** Type of file, between: IMAGE | VIDEO */
compressType: string,
/** File collected from multer middleware */
file: Express.Multer.File,
/** Return options */
fileReturnOptions: Record<string, string | undefined>,
): Promise<void> {
if (!existsSync(modulePath)) {
const { LoggingService } = await import("#services/file-system-handlers/logService.js");
const logger = new LoggingService();
await logger.logErrorAsync("wrapCompression", new Error("Module path not found"), null);
throw new Error("Missing compression service");
}
return new Promise<void>((resolve, reject) => {
const child = fork(modulePath);
child.on("error", (err) => reject(new Error(err.message)));
child.send({ compressType, file, compressSize });
child.on("message", (message: { statusCode: number; text?: string; path: { [key: string]: string } }) => {
if (message.statusCode == 500) reject(new Error(<string>message.text));
else {
for (const [key, value] of Object.entries(message.path)) fileReturnOptions[key] = value;
resolve();
}
});
});
}
export function getBoolean(value: unknown) {
switch (value) {
case true:
case "true":
case 1:
case "1":
case "on":
case "yes":
return true;
default:
return false;
}
}
export default {
wrapCompression,
getBoolean,
};
import { check, body } from "express-validator";
export const validateEmail = () => [
body("email", {
vi: "Email không được để trống",
en: "Email can not be empty",
})
.not()
.isEmpty(),
body("email", {
vi: "Email không hợp lệ",
en: "Email is invalid",
}).isEmail(),
];
export const validateRegisterUser = () => {
return [
body("full_name", {
vi: "Tên không được để trống",
en: "Name can not be empty",
})
.not()
.isEmpty(),
body("phone", {
vi: "Số điện thoại không được để trống",
en: "Phone number can not be empty",
})
.not()
.isEmpty(),
body("phone", {
vi: "Số điện thoại không hợp lệ",
en: "Phone number is invalid",
}).isMobilePhone("vi-VN"),
body("password", {
vi: "Mật khẩu không được để trống",
en: "Password can not be empty",
})
.not()
.isEmpty(),
body("password", {
vi: "Mật khẩu không thể chứa khoảng trắng",
en: "Password can not contain spaces",
})
.not()
.contains(" "),
body("password", {
vi: "Mật khẩu cần ít nhất 8 ký tự",
en: "Password needs at least 8 characters",
}).isLength({ min: 8 }),
// check("re_password")
// .isLength({ min: 1 })
// .withMessage("Xác nhận mật khẩu bắt buộc nhập"),
// check('birthday', 'Invalid birthday').isISO8601('yyyy-mm-dd'),
// check('password', 'password more than 6 degits').isLength({ min: 6 })
];
};
export const validateLogin = () => {
return [
check("email", "Email không được để trống").not().isEmpty(),
check("email", "Email không hợp lệ").isEmail(),
check("password", "Mật khẩu ít nhất 6 ký tự").isLength({ min: 6 }),
check("password", "Mật khẩu tối đa 19 ký tự").isLength({ max: 19 }),
];
};
export const validateForgetPassword = () => {
return [
check("email", "Email không được để trống").not().isEmpty(),
check("email", "Email không hợp lệ").isEmail(),
check("email", "Email ít nhất 3 ký tự").isLength({ min: 3 }),
check("email", "Email tối đa 50 ký tự").isLength({ max: 50 }),
];
};
export default {
validateEmail,
validateRegisterUser,
validateLogin,
validateForgetPassword,
};
import { Op } from "sequelize";
import dayjs from "dayjs";
enum Operators {
Equal = "==",
NotEqual = "!=",
GreaterOrEqual = ">=",
LessOrEqual = "<=",
Greater = ">",
Less = "<",
Contains = "@=",
StartsWith = "_=",
DoesNotContain = "!@=",
DoesNotStartWith = "!_=",
DateBetween = "[]",
}
const listOperators = [
{ operator: "==", meaning: "Equals" },
{ operator: "!=", meaning: "Not equals" },
{ operator: ">=", meaning: "Greater than or equal to" },
{ operator: "<=", meaning: "Less than or equal to" },
{ operator: ">", meaning: "Greater than" },
{ operator: "<", meaning: "Less than" },
{ operator: "@=", meaning: "Contains" },
{ operator: "_=", meaning: "Starts with" },
{ operator: "!@=", meaning: "Does not Contains" },
{ operator: "!_=", meaning: "Does not Starts with" },
{ operator: "[]", meaning: "Only datetime, date between two date" },
];
const getBetweenCondition = (str: string) => str.replace(/[()]/g, "").trim();
function genCondition(arrLeftRight: string[], character: string): { [op: string]: unknown } {
let conditionLeft: string = arrLeftRight[0] ?? "";
if (conditionLeft.includes(".")) conditionLeft = `$${conditionLeft}$`;
let conditionRight: string | null = arrLeftRight[1] ?? null;
if (conditionRight == "null") conditionRight = null;
switch (character) {
case Operators.Equal:
return { [conditionLeft]: conditionRight };
case Operators.NotEqual:
return { [conditionLeft]: { [Op.not]: conditionRight } };
case Operators.GreaterOrEqual:
return {
[conditionLeft]: {
[Op.gte]: conditionRight,
},
};
case Operators.LessOrEqual:
return {
[conditionLeft]: {
[Op.lte]: conditionRight,
},
};
case Operators.Greater:
return {
[conditionLeft]: {
[Op.gt]: conditionRight,
},
};
case Operators.Less:
return {
[conditionLeft]: {
[Op.lt]: conditionRight,
},
};
case Operators.Contains:
return {
[conditionLeft]: {
[Op.iLike]: "%" + conditionRight + "%",
},
};
case Operators.StartsWith:
return {
[conditionLeft]: {
[Op.startsWith]: conditionRight,
},
};
case Operators.DoesNotContain:
return {
[conditionLeft]: {
[Op.notLike]: "%" + conditionRight + "%",
},
};
case Operators.DoesNotStartWith:
return {
[conditionLeft]: {
[Op.notILike]: "%" + conditionRight,
},
};
case Operators.DateBetween: {
if (!conditionRight) return {};
const valSearch = getBetweenCondition(conditionRight);
const [start, end] = valSearch.split("-").map((data) => new Date(data));
return {
[conditionLeft]: {
[Op.between]: [start, end],
},
};
}
default:
return {};
}
}
export function generateConditionExtra(params: string): { [op: string]: unknown }[] | null {
try {
const character = listOperators.find(({ operator }) => params.includes(operator))?.operator ?? "";
const [leftOp, rightOp] = params.split(character).map((data) => data.trim());
if (!leftOp || !rightOp) return null;
const conditionRight = rightOp.replace(/[()]/g, "").split("|");
const conditionReturn: Array<{ [op: string]: unknown }> = [];
const arr: string[] = [];
if (!leftOp.includes("|")) arr.push(leftOp);
else arr.push(...getBetweenCondition(leftOp).split("|"));
arr.forEach((element) => {
conditionRight.forEach((right) => {
const arrAppend = [element, right.trim()];
const obj = genCondition(arrAppend, character);
conditionReturn.push(obj);
});
});
return conditionReturn;
} catch (ex) {
return [];
}
}
export function generateCondition(params: string): { [op: string]: unknown } {
try {
const character = listOperators.find(({ operator }) => params.includes(operator))?.operator;
if (!character) return {};
const [leftOp, rightOp] = params.split(character).map((data) => data.trim());
if (!leftOp || !rightOp) return {};
return genCondition([leftOp, rightOp], character);
} catch (ex) {
return {};
}
}
export function getCorrectFormatTime(value: string | number | Date | dayjs.Dayjs) {
const arrayFormat = ["YYYY/MM/DD", "YYYY-MM-DD", "DD/MM/YYYY", "DD-MM-YYYY"];
for (let index = 0; index < arrayFormat.length; index++) {
const element = arrayFormat[index];
if (dayjs(value, element, true).isValid()) {
return element;
}
}
return null;
}
export default {
generateCondition,
generateConditionExtra,
getCorrectFormatTime,
};
import { initModels } from "../../../models/init-models";
import sequelize from "./service";
const initExport = initModels(sequelize);
export default initExport;
import { Sequelize } from "sequelize";
import LoggingService from "../../file-system-handlers/logService";
const sequelize: Sequelize = new Sequelize({
username: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
host: process.env.DB_HOST!,
port: parseInt(process.env.DB_PORT || "5432"),
dialect: "postgres", // Default to postgres, can be overridden
logging(sql) {
const logger = new LoggingService();
logger.logDBAsync(sql);
},
});
export default sequelize;
import { FindAttributeOptions, IncludeOptions, Model, ModelStatic, WhereOptions } from "sequelize";
export interface SequelizeApiPaginatePayload<T = unknown> {
attributes?: FindAttributeOptions;
pageSize: number;
page: number;
sortField: string;
sortOrder: string;
filters: WhereOptions<T>;
rawFilter: string;
dateField: Array<{
opType: string;
indexOp: number;
}>;
}
export interface QueryOptions<T extends Model<any, any>> {
model: ModelStatic<T>;
payload?: SequelizeApiPaginatePayload<T>;
includeModels?: IncludeOptions[];
isHierarchy?: boolean;
raw?: boolean;
nest?: boolean;
distinct?: boolean;
subQuery?: unknown;
}
// import ffmpeg from "fluent-ffmpeg";
import sharp from "sharp";
import LoggingService from "./logService";
process.on("message", async (payload: { file: Express.Multer.File; compressType: string; compressSize: string }) => {
const endProcess = ({
startTime,
...endPayload
}: {
startTime: number;
statusCode: number;
text: string;
path?: any;
}) => {
console.log(`🚀 ~ Worker ${process.pid} finished compressing in ${performance.now() - startTime}ms`);
// Format response so it fits the api response
if (process.send) {
process.send(endPayload);
}
// End process
process.exit();
},
qualityFn = (compressSize: string) => {
switch (compressSize) {
case "tablet":
return 50;
case "mobile":
return 10;
case "preload":
return 1;
default:
return 90;
}
},
{ compressType, file, compressSize } = payload,
quality = qualityFn(compressSize),
filename = file.filename.split("."),
extension = file.originalname.split(".").pop(), // Get the extension from the file path
startTime = performance.now();
console.log(`🚀 ~ Worker ${process.pid} is compressing ${file.filename} to ${compressSize} quality`);
try {
let compressedFilePath = "";
switch (compressType) {
case "IMAGE":
// Process image
compressedFilePath = `${file.destination}/${filename[0]}_${compressSize}.webp`;
await sharp(file.path)
.toFormat("webp")
.webp({ quality })
.resize(compressSize == "preload" ? 20 : null)
.toFile(compressedFilePath);
return endProcess({
startTime,
statusCode: 200,
text: "Upload và tối ưu thành công hình ảnh",
path: {
[compressSize]: `/images/${filename[0]}_${compressSize}.webp`,
},
});
case "VIDEO":
// Process video and send back the result
compressedFilePath = `${file.destination}/${filename[0]}_${compressSize}.${extension}`;
// await new Promise((resolve, reject) =>
// ffmpeg(file.path)
// .size(`${quality}%`)
// .outputOptions(["-c:v libx264", `-b:v ${1000}k`, "-c:a aac", "-b:a 58k", "-crf 28"])
// .on("end", resolve)
// // .on('progress', function (progress) {
// // console.log('Processing: ' + progress.percent + '% done');
// // })
// .on("error", (err: Error) => reject(new Error(err.message)))
// .save(compressedFilePath),
// );
return endProcess({
startTime,
statusCode: 200,
text: "Upload và tối ưu thành công video",
path: {
[compressSize]: `/videos/${filename[0]}_${compressSize}.${extension}`,
},
});
default:
return endProcess({
startTime,
statusCode: 204,
text: "File không thuộc danh sách cần nén",
path: { original: "/" + file.filename },
});
}
} catch (error) {
await new LoggingService().logErrorAsync(
"compressFileService",
error instanceof Error ? error : new Error(String(error)),
null,
);
return endProcess({
statusCode: 500,
text: error instanceof Error ? error.message : "An unknown error occurred",
startTime,
});
}
});
import { LOG } from "../../constants/index";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { resolve } from "path";
declare global {
var __baseDir: string;
}
class LoggingService {
logChannelType: string;
logType: string;
isLogEnabled: boolean;
logUrl: string;
logMode: string;
timeOut: number;
constructor() {
this.logChannelType = LOG.LOG_CHANNEL_TYPE;
this.logType = LOG.LOG_TYPE;
this.isLogEnabled = process.env.LOG_LEVEL !== "off";
this.logUrl = process.env.LOG_URL || "./storage/logs";
this.logMode = process.env.LOG_MODE || "file";
this.timeOut = parseInt(process.env.LOG_TIMEOUT || "5000");
}
async logAsync(category: string, source: string, message: string | Error, params: null) {
try {
if (!this.isLogEnabled) return;
let errorMessage: string | { message: string; stack?: string } | null = null;
if (message instanceof Error) {
errorMessage = { message: message.message };
if (message.stack) errorMessage.stack = message.stack;
}
if (errorMessage == null) errorMessage = message;
const // Log data
logMessage = {
messagesource: source,
message: JSON.stringify(errorMessage).replace(/(\\n)|\\|"|\s{2,}/g, ""),
logdatetime: new Date(),
parameters: JSON.stringify(params),
},
jsonData = {
service: this.logChannelType,
type: this.logType,
category: category,
message: logMessage,
};
if (["console", "both"].includes(this.logMode)) {
console.log(jsonData);
// Force flush stdout to ensure logs appear immediately
process.stdout.write("");
}
if (this.logMode != "console") {
// Logging into file system
const // Date formatters
date = new Date(),
dayLog = // Example: August 15th 2004 => 15082024
date.getDate().toString().padStart(2, "0") +
(date.getMonth() + 1).toString().padStart(2, "0") +
date.getFullYear(),
timeLog = // Example: 02:02:02 PM => 140202
date.getHours().toString().padStart(2, "0") +
date.getMinutes().toString().padStart(2, "0") +
date.getSeconds().toString().padStart(2, "0"),
timeRange = // Example: Logged at 13:45 => 1300_1400
date.getHours().toString().padStart(2, "0") +
"00_" +
(date.getHours() + 1).toString().padStart(2, "0") +
"00";
const // Path resolvers
store = resolve(
global.__baseDir.replace("/dist", ""),
"storage/logs",
category == LOG.LOG_DB_CATEGORY ? "database" : "",
),
folderPath = resolve(store, dayLog),
filePath = resolve(folderPath, `${timeRange}.log`);
const fileContent = `${dayLog}${timeLog}: ${JSON.stringify(logMessage)}\n`;
if (!existsSync(folderPath)) mkdirSync(folderPath, { recursive: true });
writeFileSync(filePath, fileContent, { flag: "a+" });
}
} catch (ex) {
console.log(ex);
// Force flush stdout to ensure logs appear immediately
process.stdout.write("");
}
}
async logErrorAsync(source: string, message: Error, params: null) {
await this.logAsync(LOG.LOG_ERROR_CATEGORY, source, message, params);
}
async logInfoAsync(source: string, message: Error, params: null) {
await this.logAsync(LOG.LOG_INFO_CATEGORY, source, message, params);
}
async logTransAsync(source: string, message: Error, params: null) {
await this.logAsync(LOG.LOG_TRANS_CATEGORY, source, message, params);
}
async logDBAsync(query: unknown) {
await this.logAsync(LOG.LOG_DB_CATEGORY, "Sequelize", String(query), null);
}
}
export default LoggingService;
export { LoggingService };
import fs from "fs";
import multer from "multer";
import constants from "#constants/index";
import path from "path";
import { promisify } from "util";
import { Req } from "#/interfaces/IApi";
import { Request } from "express";
const mimeTypes = constants.MIME_TYPES;
const fileTypeFn = (type = "DEFAULT") => {
switch (type) {
case "IMAGE":
return "images";
case "VIDEO":
return "videos";
default:
return "files";
}
};
const storage = multer.diskStorage({
destination: function (req: Request, file: Express.Multer.File, cb: (arg0: null, arg1: string) => void) {
const customReq = req as Req;
let fileType = "DEFAULT";
if (file.mimetype.startsWith("image/")) {
fileType = "IMAGE";
} else if (file.mimetype.startsWith("video/")) {
fileType = "VIDEO";
}
const storagePath = path.resolve(
process.env.UPLOAD_PATH || "./storage/uploads",
fileTypeFn(fileType),
<string>customReq.imagePath ?? "",
);
fs.mkdirSync(storagePath, { recursive: true });
cb(null, storagePath);
},
filename: function (
req: Request,
file: { fieldname: string; originalname: string },
cb: (arg0: null, arg1: string) => void,
) {
const customReq = req as Req;
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
cb(null, `${file.fieldname}-${uniqueSuffix}${customReq.extendName ?? ""}${path.extname(file.originalname)}`);
},
}),
upload = multer({ storage: storage }).single("file");
export default promisify(upload);
import nodemailer from "nodemailer";
import { SentMessageInfo } from "nodemailer/lib/smtp-transport";
import { Options } from "nodemailer/lib/mailer";
class MailService {
transporter?: nodemailer.Transporter<SentMessageInfo>;
constructor() {
this.transporter ??= nodemailer.createTransport({
host: process.env.EMAIL_HOST || "smtp.gmail.com",
port: parseInt(process.env.EMAIL_PORT || "587"),
secure: false, // true for 465, false for other ports
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
}
// async replaceMailTemplate(sHtml: string, oReplace: string | any[]) {
// let sReturn = sHtml;
// for (let i = 0; i < oReplace.length; i++) {
// sReturn = sReturn.replace(oReplace[i].replace, oReplace[i].textReplace);
// }
// return sReturn;
// }
async sendmail(mailOptions: Options) {
return this.transporter!.sendMail(mailOptions);
}
}
export default MailService;
const CLASS_NAME = "schedule";
import LoggingService from "./file-system-handlers/logService";
import schedule, { Job } from "node-schedule";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import customParseFormat from "dayjs/plugin/customParseFormat";
// import { UserProvider } from "../providers/userProvider"; // Uncomment when you have a user provider
dayjs.extend(utc);
dayjs.extend(customParseFormat);
class ScheduleClass {
logger: LoggingService;
job: Record<string, Job> = {};
// userProvider: UserProvider; // Uncomment when you have a user provider
constructor() {
this.logger = new LoggingService();
// this.userProvider = new UserProvider(); // Uncomment when you have a user provider
}
init() {
const METHOD_NAME = "Init Job Scheduling";
const SOURCE = `${CLASS_NAME}.${METHOD_NAME}`;
this.logger.logAsync("SCHEDULE", SOURCE, "Initialize schedule", null);
this.job["midnight"] = schedule.scheduleJob("0 0 * * *", async function () {});
this.job["perMin"] = schedule.scheduleJob("* * * * *", async function () {});
}
}
export default ScheduleClass;
import initExport from "#services/database/sequelize/initExport";
import LoggingService from "#services/file-system-handlers/logService";
import {
ModelStatic,
Model,
CreationAttributes,
WhereOptions,
FindAndCountOptions,
Order,
FindAttributeOptions,
Includeable,
Attributes,
Transaction,
BulkCreateOptions,
UpdateOptions,
DestroyOptions,
Op,
} from "sequelize";
import { MeUError } from "#interfaces/IApi";
import { FindManyOptions, FindManyReturnModel } from "#interfaces/IProvider";
// Enhanced cache interface supporting Redis and in-memory
interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, data: T, ttlSeconds?: number): Promise<void>;
delete(key: string): Promise<void>;
invalidatePattern(pattern: string): Promise<void>;
}
// Simple in-memory cache for development
class SimpleCache implements CacheService {
private cache = new Map<string, { data: any; expiry: number }>();
async get<T>(key: string): Promise<T | null> {
const item = this.cache.get(key);
if (!item || Date.now() > item.expiry) {
this.cache.delete(key);
return null;
}
return item.data;
}
async set<T>(key: string, data: T, ttlSeconds: number = 300): Promise<void> {
this.cache.set(key, {
data,
expiry: Date.now() + ttlSeconds * 1000,
});
}
async delete(key: string): Promise<void> {
this.cache.delete(key);
}
async invalidatePattern(pattern: string): Promise<void> {
const regex = new RegExp(pattern.replace("*", ".*"));
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
}
}
}
}
// Redis cache implementation (placeholder - implement based on your Redis setup)
class RedisCache implements CacheService {
async get<T>(key: string): Promise<T | null> {
// TODO: Implement Redis get
return null;
}
async set<T>(key: string, data: T, ttlSeconds: number = 300): Promise<void> {
// TODO: Implement Redis set
}
async delete(key: string): Promise<void> {
// TODO: Implement Redis delete
}
async invalidatePattern(pattern: string): Promise<void> {
// TODO: Implement Redis pattern invalidation
}
}
class BaseProvider<ModelInterface extends Model> {
private db: typeof initExport;
private key: string;
private model: ModelStatic<ModelInterface>;
private logger: LoggingService;
private cache: CacheService;
private enableCache: boolean;
private uniqueFields: string[] = [];
private enableRedisCache: boolean;
constructor(
key: string,
skipInitDb?: boolean,
enableCache: boolean = true,
enableRedisCache: boolean = false,
uniqueFields: string[] = [],
) {
this.key = key;
this.db = skipInitDb ? ({} as any) : initExport;
this.model = skipInitDb ? ({} as any) : (this.db as any)[this.key];
this.logger = new LoggingService();
this.enableCache = enableCache && process.env.NODE_ENV === "production";
this.enableRedisCache = enableRedisCache;
this.uniqueFields = uniqueFields;
// Initialize appropriate cache service
this.cache = this.enableRedisCache ? new RedisCache() : new SimpleCache();
}
// ==================== CACHE MANAGEMENT ====================
protected getCacheKey(operation: string, params: any = {}): string {
try {
// Create a safe key by limiting size and avoiding circular references
const safeParams = JSON.stringify(params, (key, value) => {
if (typeof value === "object" && value !== null) {
// Limit object depth and size
return value;
}
return value;
});
return `${this.key}:${operation}:${safeParams}`.substring(0, 200); // Limit key length
} catch (error) {
// Fallback to simple key if JSON stringify fails
return `${this.key}:${operation}:${Date.now()}`;
}
}
protected async getCachedResult<T>(cacheKey: string, queryFn: () => Promise<T>, ttl: number = 300): Promise<T> {
if (!this.enableCache) return queryFn();
const cached = await this.cache.get<T>(cacheKey);
if (cached) {
await this.logger.logInfoAsync("Cache", new Error(`Cache hit for ${cacheKey}`), null);
return cached;
}
const result = await queryFn();
await this.cache.set(cacheKey, result, ttl);
return result;
}
protected async invalidateCache(): Promise<void> {
if (!this.enableCache) return;
await this.cache.invalidatePattern(`${this.key}:*`);
}
// ==================== UNIQUE VALIDATION ====================
async checkUnique(
data: object,
options?: {
uniqueFields?: string[];
where?: WhereOptions<Attributes<ModelInterface>>;
ignoreAttributes?: string[];
},
): Promise<{ isValid: boolean; duplicateFields: string[] }> {
const uniqueFields = options?.uniqueFields || this.uniqueFields;
if (uniqueFields.length === 0) return { isValid: true, duplicateFields: [] };
const duplicateFields: string[] = [];
const whereClause = options?.where || {};
const ignoreAttributes = options?.ignoreAttributes || [];
for (const field of uniqueFields) {
if (ignoreAttributes.includes(field)) continue;
const keys = field.split(",");
const fieldWhere: WhereOptions<Attributes<ModelInterface>> = { ...whereClause };
let hasValue = false;
for (const key of keys) {
const value = (data as any)[key];
if (value !== undefined && value !== null) {
(fieldWhere as any)[key] = value;
hasValue = true;
}
}
if (!hasValue) continue;
try {
const existing = await this.model.findOne({ where: fieldWhere, attributes: ["id"] });
if (existing) {
duplicateFields.push(field);
}
} catch (error) {
// Log error but continue checking other fields
await this.logger.logErrorAsync("Provider", new Error(`Check unique failed: ${error}`), null);
}
}
return {
isValid: duplicateFields.length === 0,
duplicateFields,
};
}
// ==================== TRANSACTION MANAGEMENT ====================
public get transaction() {
return (this.model as any).sequelize.transaction;
}
protected async executeInTransaction<T>(
fn: (transaction: Transaction) => Promise<T>,
existingTransaction?: Transaction | null,
): Promise<T> {
if (existingTransaction) {
return fn(existingTransaction);
}
const transaction = await (this.model as any).sequelize.transaction();
try {
const result = await fn(transaction);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
// ==================== QUERY METHODS ====================
protected transformQueryOptions(opts: FindManyOptions<ModelInterface>) {
const { page, pageSize, sortField, sortOrder, where, ...baseQuery } = opts;
const payload: FindAndCountOptions = {
...baseQuery,
};
if (where && where !== null) {
payload.where = where;
}
if (sortField && sortOrder) {
payload.order = [[sortField, sortOrder]];
}
if (page !== undefined && page < 0) throw new MeUError(410, "DB", { page });
if (pageSize !== undefined && pageSize < 0) throw new MeUError(411, "DB", { pageSize });
if (pageSize !== undefined && pageSize > 0) {
payload.limit = pageSize;
payload.offset = ((page || 1) - 1) * pageSize || 0;
}
return payload;
}
public async getAll(opts: FindManyOptions<ModelInterface>): Promise<FindManyReturnModel<ModelInterface>> {
const cacheKey = this.getCacheKey("getAll", opts);
return this.getCachedResult(
cacheKey,
async () => {
try {
const payload = this.transformQueryOptions(opts);
const { count, rows } = await (this.model as any).findAndCountAll(payload);
const result: FindManyReturnModel<ModelInterface> = {
count,
rows,
};
if (opts.page !== undefined) result.page = opts.page;
if (opts.pageSize !== undefined) result.pageSize = opts.pageSize;
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
},
300,
);
}
async getById({
id,
includeAttributes = Object.keys(this.model.getAttributes()),
includeModels = [],
transaction,
}: {
id: string;
includeAttributes?: FindAttributeOptions;
includeModels?: Includeable | Includeable[];
transaction?: Transaction;
}): Promise<ModelInterface> {
const cacheKey = this.getCacheKey("getById", { id, includeAttributes, includeModels });
return this.getCachedResult(
cacheKey,
async () => {
try {
const result = await (this.model as any).findByPk(id, {
include: includeModels,
attributes: includeAttributes ?? Object.keys((this.model as any).getAttributes()),
transaction,
});
if (!result) {
throw new MeUError(404, "DB", `Record with id ${id} not found`);
}
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
},
600,
);
}
async getOne({
where,
includeAttributes = Object.keys(this.model.getAttributes()),
includeModels = [],
raw = false,
nest = false,
order,
transaction,
}: {
where: WhereOptions<Attributes<ModelInterface>> | null;
includeAttributes?: FindAttributeOptions;
includeModels?: Includeable | Includeable[];
raw?: boolean;
nest?: boolean;
order?: Order;
transaction?: Transaction;
}): Promise<ModelInterface | null> {
const cacheKey = this.getCacheKey("getOne", { where, includeAttributes, includeModels, order });
return this.getCachedResult(
cacheKey,
async () => {
try {
const attributes =
includeAttributes === null ? Object.keys((this.model as any).getAttributes()) : includeAttributes;
return await (this.model as any).findOne({
where: where || {},
include: includeModels,
attributes,
raw,
nest,
order,
transaction,
});
} catch (error) {
await this.logError(error as Error);
throw error;
}
},
300,
);
}
async getOneOrCreate({
where,
body,
transaction,
order,
}: {
where: WhereOptions<Attributes<ModelInterface>> | null;
body: CreationAttributes<ModelInterface>;
transaction?: Transaction;
order?: Order;
}): Promise<[ModelInterface, boolean]> {
return this.executeInTransaction(async (tx) => {
try {
const result = await (this.model as any).findOrCreate({
where,
order: order || [["created_at", "DESC"]],
defaults: body,
transaction: tx,
});
await this.invalidateCache();
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, transaction);
}
// ==================== MUTATION METHODS ====================
async post(
body: CreationAttributes<ModelInterface>,
options?: {
transaction?: Transaction;
skipUniqueCheck?: boolean;
uniqueFields?: string[];
},
): Promise<ModelInterface> {
return this.executeInTransaction(async (tx) => {
try {
// Check unique constraints
if (!options?.skipUniqueCheck) {
const uniqueCheck = await this.checkUnique(body, {
uniqueFields: options?.uniqueFields || [],
});
if (!uniqueCheck.isValid) {
throw new MeUError(409, "DB", {
duplicateFields: uniqueCheck.duplicateFields,
});
}
}
const result = await (this.model as any).create(body, { transaction: tx });
await this.logger.logInfoAsync("Provider", new Error(`Created ${this.key} with id ${result.get("id")}`), null);
await this.invalidateCache();
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, options?.transaction);
}
async bulkCreate(
body: Array<CreationAttributes<ModelInterface>>,
options?: BulkCreateOptions,
): Promise<ModelInterface[]> {
return this.executeInTransaction(async (tx) => {
try {
const result = await this.model.bulkCreate(body, {
...options,
transaction: tx,
});
await this.logger.logInfoAsync(
"Provider",
new Error(`Bulk created ${result.length} ${this.key} records`),
null,
);
await this.invalidateCache();
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, options?.transaction);
}
async put(
id: string,
body: CreationAttributes<ModelInterface>,
options?: {
transaction?: Transaction;
skipUniqueCheck?: boolean;
uniqueFields?: string[];
},
): Promise<ModelInterface> {
return this.executeInTransaction(async (tx) => {
try {
const toUpdate = await this.model.findByPk(id, { transaction: tx });
if (!toUpdate) {
throw new MeUError(404, "DB", `Record with id ${id} not found`);
}
// Check unique constraints
if (!options?.skipUniqueCheck) {
const uniqueCheck = await this.checkUnique(body, {
uniqueFields: options?.uniqueFields || [],
where: { [Op.not]: { id } } as WhereOptions<ModelInterface>,
});
if (!uniqueCheck.isValid) {
throw new MeUError(409, "DB", {
duplicateFields: uniqueCheck.duplicateFields,
});
}
}
const result = await toUpdate.update({ ...body, updated_at: new Date() }, { transaction: tx });
await this.logger.logInfoAsync("Provider", new Error(`Updated ${this.key} with id ${id}`), null);
await this.invalidateCache();
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, options?.transaction);
}
async bulkUpdate(
where: WhereOptions<ModelInterface>,
body: CreationAttributes<ModelInterface>,
options?: UpdateOptions<ModelInterface>,
): Promise<[affectedCount: number]> {
return this.executeInTransaction(async (tx) => {
try {
const result = await this.model.update(body, {
...options,
where,
transaction: tx,
});
await this.logger.logInfoAsync("Provider", new Error(`Bulk updated ${result[0]} ${this.key} records`), null);
await this.invalidateCache();
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, options?.transaction);
}
async delete(
id: string,
options?: {
transaction?: Transaction;
force?: boolean;
},
): Promise<string> {
return this.executeInTransaction(async (tx) => {
try {
const toDelete = await this.model.findByPk(id, { transaction: tx });
if (!toDelete) {
throw new MeUError(404, "DB", `Record with id ${id} not found`);
}
await toDelete.destroy({
transaction: tx,
force: options?.force || false,
});
await this.logger.logInfoAsync("Provider", new Error(`Deleted ${this.key} with id ${id}`), null);
await this.invalidateCache();
return "Successfully delete item";
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, options?.transaction);
}
async bulkDelete(
where: WhereOptions<Attributes<ModelInterface>>,
options?: DestroyOptions<ModelInterface>,
): Promise<number> {
return this.executeInTransaction(async (tx) => {
try {
const result = await this.model.destroy({
...options,
where,
transaction: tx,
});
await this.logger.logInfoAsync("Provider", new Error(`Bulk deleted ${result} ${this.key} records`), null);
await this.invalidateCache();
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, options?.transaction);
}
// ==================== UTILITY METHODS ====================
async exists(where: WhereOptions<Attributes<ModelInterface>>): Promise<boolean> {
const count = await this.model.count({ where });
return count > 0;
}
async count(where?: WhereOptions<Attributes<ModelInterface>>): Promise<number> {
const cacheKey = this.getCacheKey("count", { where });
return this.getCachedResult(
cacheKey,
async () => {
return await (this.model as any).count({ where });
},
180,
);
}
async findAll(options?: FindAndCountOptions): Promise<ModelInterface[]> {
const cacheKey = this.getCacheKey("findAll", options);
return this.getCachedResult(
cacheKey,
async () => {
try {
return await this.model.findAll(options);
} catch (error) {
await this.logError(error as Error);
throw error;
}
},
300,
);
}
async findByPk(id: any, options?: any): Promise<ModelInterface | null> {
const cacheKey = this.getCacheKey("findByPk", { id, options });
return this.getCachedResult(
cacheKey,
async () => {
try {
return await this.model.findByPk(id, options);
} catch (error) {
await this.logError(error as Error);
throw error;
}
},
300,
);
}
async create(data: CreationAttributes<ModelInterface>, options?: any): Promise<ModelInterface> {
return this.post(data, options);
}
async updateById(
id: string,
data: Partial<Attributes<ModelInterface>>,
options?: UpdateOptions<ModelInterface>,
): Promise<ModelInterface | null> {
const putOptions: any = {
skipUniqueCheck: false,
uniqueFields: [],
};
if (options?.transaction) {
putOptions.transaction = options.transaction;
}
return this.put(id, data as CreationAttributes<ModelInterface>, putOptions);
}
async logError(err: MeUError | Error) {
await this.logger.logErrorAsync("Provider", err, null);
return;
}
// ==================== ADVANCED FEATURES ====================
/**
* Optimized method for getting paginated results with search
*/
async getPaginatedWithSearch(
opts: FindManyOptions<ModelInterface> & {
searchFields?: string[];
searchQuery?: string;
},
): Promise<FindManyReturnModel<ModelInterface>> {
const { searchFields, searchQuery, ...queryOpts } = opts;
if (searchQuery && searchFields?.length) {
const searchWhere = searchFields.map((field) => ({
[field]: { [Op.iLike]: `%${searchQuery}%` },
}));
queryOpts.where = {
...queryOpts.where,
[Op.or]: searchWhere,
} as WhereOptions<ModelInterface>;
}
return this.getAll(queryOpts);
}
/**
* Method for upsert operations
*/
async upsert(
values: CreationAttributes<ModelInterface>,
options?: {
transaction?: Transaction;
conflictFields?: string[];
},
): Promise<ModelInterface> {
return this.executeInTransaction(async (tx) => {
try {
const result = await this.model.upsert(values, {
...options,
transaction: tx,
returning: true,
});
await this.invalidateCache();
return result[0];
} catch (error) {
await this.logError(error as Error);
throw error;
}
}, options?.transaction);
}
}
export { BaseProvider };
export default BaseProvider;
module.exports = {
LoginRequest: {
type: "object",
required: ["email", "password"],
properties: {
email: {
type: "string",
format: "email",
example: "user@example.com",
},
password: {
type: "string",
example: "password123",
},
device_info: {
type: "object",
description: "Device information for session tracking",
},
ip_address: {
type: "string",
description: "IP address for security logging",
},
user_agent: {
type: "string",
description: "User agent string",
},
},
},
LoginResponse: {
type: "object",
properties: {
access_token: {
type: "string",
description: "JWT access token",
},
refresh_token: {
type: "string",
description: "JWT refresh token",
},
expires_in: {
type: "number",
description: "Access token expiration time in seconds",
},
refresh_expires_in: {
type: "number",
description: "Refresh token expiration time in seconds",
},
token_type: {
type: "string",
example: "Bearer",
},
},
required: ["access_token", "refresh_token", "expires_in", "refresh_expires_in", "token_type"],
},
};
module.exports = {
// Fetch queries
filters: {
name: "filters",
in: "query",
description: "filter, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax",
schema: {
type: "string",
},
},
sortField: {
name: "sortField",
in: "query",
description: "sortField, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax",
schema: {
type: "string",
},
},
sortOrder: {
name: "sortOrder",
in: "query",
description: "sort order, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax",
schema: {
type: "string",
enum: ["asc", "desc"],
},
},
page: {
name: "page",
in: "query",
description: "page, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax",
schema: {
type: "integer",
minimum: 1,
},
},
pageSize: {
name: "pageSize",
in: "query",
description: "pageSize, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax",
schema: {
type: "integer",
minimum: 1,
},
},
// Mutate queries
filtersMutate: {
name: "filters",
in: "query",
description: "filter, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax",
schema: {
type: "string",
},
required: true,
},
};
module.exports = {
BadRequest: {
description: "Bad Request",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
example: false,
},
error: {
type: "object",
properties: {
code: {
type: "string",
example: "VALIDATION_ERROR",
},
message: {
type: "object",
properties: {
vi: {
type: "string",
example: "Dữ liệu không hợp lệ",
},
en: {
type: "string",
example: "Invalid data",
},
},
},
},
},
message: {
type: "string",
nullable: true,
},
message_en: {
type: "string",
nullable: true,
},
responseData: {
type: "object",
nullable: true,
},
status: {
type: "string",
example: "fail",
},
timeStamp: {
type: "string",
example: "2025-12-07 10:00:00",
},
violation: {
type: "array",
items: {
type: "object",
},
nullable: true,
},
},
},
},
},
},
Unauthorized: {
description: "Unauthorized",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
example: false,
},
error: {
type: "object",
properties: {
code: {
type: "string",
example: "UNAUTHORIZED",
},
message: {
type: "object",
properties: {
vi: {
type: "string",
example: "Không được phép truy cập",
},
en: {
type: "string",
example: "Unauthorized access",
},
},
},
},
},
message: {
type: "string",
nullable: true,
},
message_en: {
type: "string",
nullable: true,
},
responseData: {
type: "object",
nullable: true,
},
status: {
type: "string",
example: "fail",
},
timeStamp: {
type: "string",
example: "2025-12-07 10:00:00",
},
violation: {
type: "array",
items: {
type: "object",
},
nullable: true,
},
},
},
},
},
},
};
module.exports = {
Response: {
type: "object",
properties: {
message: {
type: "string",
},
message_en: {
type: "string",
},
responseData: {
type: "object",
},
status: {
type: "string",
example: "success | fail",
},
timeStamp: {
type: "string",
example: "2024-02-26 03:12:45",
},
violation: {
type: "array",
items: {
type: "object",
},
},
},
},
responseGetAllData: {
allOf: [
{ $ref: "#/components/schemas/Response" },
{
type: "object",
properties: {
responseData: {
type: "object",
properties: {
count: {
type: "number",
},
page: {
type: "number",
},
pageSize: {
type: "number",
},
rows: {
type: "array",
items: {
type: "object",
},
},
},
},
},
},
],
},
};
module.exports = {
Bearer: {
name: "Authorization",
in: "header",
type: "apiKey",
},
};
/** @type {import("swagger-jsdoc").Options} */
module.exports = {
definition: {
openapi: "3.1.0",
info: {
version: "1.0.0",
title: process.env.PROJECT_NAME || "Backend Template",
description: "Coded by Meu TEAM",
},
servers: [{ url: `${process.env.BACKEND_URL || "http://localhost:3001"}/api/v1.0` }],
components: {
securitySchemes: require("./common/securitySchemes"),
parameters: require("./common/parameters"),
schemas: {
...require("./common/schemas"),
...require("./auth/schemas"),
},
responses: require("./common/responses"),
},
},
apis: ["./dist/controllers/**/*.js"],
};
import { User } from "../models/User";
import { UserAuth } from "../models/UserAuth";
import { UserSession } from "../models/UserSession";
import dayjs from "dayjs";
/**
* Authentication utility functions
* These functions provide the logic that would normally be instance methods
* but are kept separate to avoid conflicts with auto-generated models
*/
/**
* Maximum number of login attempts before account lockout
*/
const MAX_LOGIN_ATTEMPTS = 5;
/**
* Check if user account is locked due to too many failed login attempts
*/
export const isUserLocked = (userAuth: UserAuth): boolean => {
if (userAuth.locked_until) {
const now = dayjs();
const lockedUntil = dayjs(userAuth.locked_until);
return now.isBefore(lockedUntil);
}
return false;
};
/**
* Increment login attempts for a user
*/
export const incrementUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
const currentAttempts = (userAuthInstance.login_attempts || 0) + 1;
// Lock account if too many attempts
const updates: any = { login_attempts: currentAttempts };
if (currentAttempts >= MAX_LOGIN_ATTEMPTS) {
updates.locked_until = dayjs().add(15, "minutes").toDate();
}
await userAuthInstance.update(updates);
};
/**
* Reset login attempts for a user (on successful login)
*/
export const resetUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
login_attempts: 0,
locked_until: new Date(0),
});
};
/**
* Unlock user account (reset lockout)
*/
export const unlockUserAccount = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
locked_until: new Date(0),
login_attempts: 0,
});
};
/**
* Check if user account lockout has expired and unlock if needed
*/
export const checkAndUnlockExpiredLockout = async (userAuthInstance: UserAuth): Promise<boolean> => {
if (userAuthInstance.locked_until) {
const now = dayjs();
const lockedUntil = dayjs(userAuthInstance.locked_until);
if (now.isAfter(lockedUntil)) {
await unlockUserAccount(userAuthInstance);
return true; // Account was unlocked
}
}
return false; // Account was not locked or still locked
};
/**
* Update user's last login timestamp
*/
export const updateUserLastLogin = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
last_login_at: new Date(),
});
};
/**
* Check if user session is expired
*/
export const isSessionExpired = (session: UserSession): boolean => {
const now = dayjs();
const expiresAt = dayjs(session.expires_at);
return now.isAfter(expiresAt);
};
/**
* Check if user session refresh token is expired
*/
export const isSessionRefreshExpired = (session: UserSession): boolean => {
const now = dayjs();
const refreshExpiresAt = dayjs(session.refresh_expires_at);
return now.isAfter(refreshExpiresAt);
};
/**
* Deactivate a user session
*/
export const deactivateUserSession = async (sessionInstance: UserSession): Promise<void> => {
await sessionInstance.update({ is_active: false });
};
/**
* Update session activity timestamp
*/
export const updateSessionActivity = async (sessionInstance: UserSession): Promise<void> => {
await sessionInstance.update({ last_activity_at: new Date() });
};
/**
* Check if user can change password (not recently changed)
*/
export const canUserChangePassword = (userAuth: UserAuth, minDaysBetweenChanges: number = 1): boolean => {
if (!userAuth.password_changed_at) return true;
const lastChange = dayjs(userAuth.password_changed_at);
const now = dayjs();
const daysSinceChange = now.diff(lastChange, "day");
return daysSinceChange >= minDaysBetweenChanges;
};
/**
* Update user's password changed timestamp
*/
export const updateUserPasswordChanged = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
password_changed_at: new Date(),
});
};
/**
* Check if password was used recently (prevent reuse)
*/
export const isPasswordRecentlyUsed = (
userAuth: UserAuth,
passwordHistory: string[],
newPasswordHash: string,
): boolean => {
// Check if new password hash matches any in recent history
return passwordHistory.includes(newPasswordHash);
};
/**
* Get user full name
*/
export const getUserFullName = (user: User): string => {
const firstName = user.first_name || "";
const lastName = user.last_name || "";
return `${firstName} ${lastName}`.trim();
};
/**
* Check if user has specific role
*/
export const hasUserRole = (user: User, role: string): boolean => {
return (user as any).role?.name === role;
};
/**
* Check if user has admin privileges
*/
export const isUserAdmin = (user: User): boolean => {
return (user as any).role?.name === "admin" || (user as any).role?.name === "system_admin";
};
/**
* Check if user is system admin
*/
export const isUserSystemAdmin = (user: User): boolean => {
return (user as any).role?.name === "system_admin";
};
/**
* Get user status display text
*/
export const getUserStatusText = (status: string): { vi: string; en: string } => {
const statusMap: Record<string, { vi: string; en: string }> = {
active: { vi: "Hoạt động", en: "Active" },
inactive: { vi: "Không hoạt động", en: "Inactive" },
suspended: { vi: "Đã tạm ngừng", en: "Suspended" },
pending_verification: { vi: "Chờ xác minh", en: "Pending Verification" },
};
return statusMap[status] || { vi: "Không xác định", en: "Unknown" };
};
/**
* Get role display text
*/
export const getRoleText = (role: string): { vi: string; en: string } => {
const roleMap: Record<string, { vi: string; en: string }> = {
user: { vi: "Người dùng", en: "User" },
admin: { vi: "Quản trị viên", en: "Administrator" },
system_admin: { vi: "Quản trị hệ thống", en: "System Administrator" },
};
return roleMap[role] || { vi: "Không xác định", en: "Unknown" };
};
{
"compilerOptions": {
"module": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"types": ["node"],
"sourceMap": false,
"removeComments": false,
"outDir": "dist",
"rootDir": "src",
"baseUrl": "src",
"declaration": false,
"incremental": true,
"tsBuildInfoFile": "dist/.tsbuildinfo",
"paths": {
"#dto/*": ["dto/*"],
"#providers/*": ["providers/*"],
"#services/*": ["services/*"],
"#middlewares/*": ["middlewares/*"],
"#models/*": ["models/*"],
"#templates/*": ["templates/*"],
"#constants/*": ["constants/*"],
"#interfaces/*": ["interfaces/*"],
"#/*": ["*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "storage", "**/*.test.ts", "**/*.spec.ts"]
}
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