chore: add basecode template from sso-vietprodev-old

Co-authored-by: 's avatarCursor <cursoragent@cursor.com>
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.*
!.env.example
# 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
tests/
coverage/
.nyc_output
test-results/
playwright-report/
# Storage (runtime data, not needed in image)
storage/
# Logs
logs/
*.log
# 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/
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
# NODE_ENV: development (relaxed security), staging, production (strict security)
NODE_ENV=development
PORT=3001
BACKEND_URL=http://localhost:3001
# FRONTEND_URL: list of allowed origins (comma-separated)
FRONTEND_URL=http://localhost:3000,http://localhost:3001
PROJECT_NAME=Bekind Backend
PROJECT_VERSION=1.0.0
# ─────────────────────────────────────────────
# Database (SECRETS - keep in .env)
# ─────────────────────────────────────────────
DB_HOST=your_db_host
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=bekind
DB_POOL_MAX=50
DB_POOL_MIN=5
DB_POOL_ACQUIRE=30000
DB_POOL_IDLE=10000
DB_POOL_EVICT=60000
# ─────────────────────────────────────────────
# Redis (SECRETS - keep in .env)
# ─────────────────────────────────────────────
REDIS_ENABLED=false
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_CONNECT_TIMEOUT=10000
REDIS_COMMAND_TIMEOUT=5000
REDIS_MAX_RETRIES=3
REDIS_IP_FAMILY=4
# Redis A (for clustering/special use)
REDIS_A_ENABLED=false
REDIS_A_HOST=localhost
REDIS_A_PORT=6379
REDIS_A_PASSWORD=
REDIS_A_CLUSTER=false
REDIS_A_CLUSTER_NODES=
# Redis B (for caching/special use)
REDIS_B_HOST=localhost
REDIS_B_PORT=6380
REDIS_B_PASSWORD=
REDIS_B_DB=0
# ─────────────────────────────────────────────
# Authentication & Security (SECRETS - keep in .env)
# ─────────────────────────────────────────────
# JWT secrets: use strong secrets in production (minimum 32 characters)
JWT_SECRET=change_this_to_strong_secret_min_32_chars
JWT_SECRET_PREVIOUS=old_secret_1,old_secret_2 # Comma-separated previous secrets for rotation
JWT_REFRESH_SECRET=change_this_to_strong_refresh_secret_min_32_chars
JWT_REFRESH_SECRET_PREVIOUS=old_refresh_secret_1,old_refresh_secret_2 # Comma-separated previous refresh secrets for rotation
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
TOKEN_ENCRYPTION_KEY=change_this_to_exactly_32_chars
TOKEN_ENCRYPTION_KEY_PREVIOUS=old_encryption_key_1,old_encryption_key_2 # Comma-separated previous encryption keys for rotation
DEFAULT_PASSWORD=change_this_default_password_hash
BCRYPT_ROUNDS=12
PASSWORD_MIN_LENGTH=8
PASSWORD_MAX_LENGTH=128
INCLUDE_TOKENS_IN_RESPONSE=false
SKIP_SENSITIVE_CONFIRM=false
ENABLE_REGISTER=true
# ─────────────────────────────────────────────
# Cookie Settings
# ─────────────────────────────────────────────
COOKIE_DOMAIN=.yourdomain.com
COOKIE_CROSS_SITE=false
FORCE_SECURE_COOKIES=true
DEV_SAMESITE_LAX=false
# ─────────────────────────────────────────────
# Upload Settings
# ─────────────────────────────────────────────
UPLOAD_PATH=./storage/uploads
MAX_FILE_SIZE=10485760
STORAGE_IMAGES_PATH=/storage/uploads/images
STORAGE_VIDEOS_PATH=/storage/uploads/videos
STORAGE_FILES_PATH=/storage/uploads/files
# ─────────────────────────────────────────────
# Storage (MinIO/S3)
# ─────────────────────────────────────────────
STORAGE_PROVIDER=minio
PUBLIC_BASE_URL=https://api.yourdomain.com
LOCAL_STORAGE_ROOT=./storage/uploads
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=your_minio_access_key
MINIO_SECRET_KEY=your_minio_secret_key
MINIO_BUCKET=uploads
MINIO_REGION=us-east-1
# ─────────────────────────────────────────────
# Email (SECRETS - keep in .env)
# ─────────────────────────────────────────────
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USER=your_email@example.com
EMAIL_PASS=your_email_password
EMAIL_FROM=noreply@yourdomain.com
# ─────────────────────────────────────────────
# Notifications & Queue
# ─────────────────────────────────────────────
QUEUE_CONCURRENCY=10
WORKER_CONCURRENCY=20
JOB_ATTEMPTS=3
JOB_BACKOFF_DELAY=2000
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=15
PERM_CACHE_TTL=300
SESSION_CACHE_TTL=900
REUSE_WINDOW_SECONDS=30
# ─────────────────────────────────────────────
# CSRF Protection
# ─────────────────────────────────────────────
CSRF_ORIGIN_VALIDATION=true
CSRF_DOUBLE_SUBMIT=true
CSRF_HTTPONLY=true
# ─────────────────────────────────────────────
# Zalo (SECRETS - keep in .env)
# ─────────────────────────────────────────────
ZALO_APP_ID=
ZALO_SECRET_KEY=
ZALO_OA_ACCESS_TOKEN=
ZALO_ZNS_ACCESS_TOKEN=
ZALO_ZNS_TEMPLATE_ID=
# ─────────────────────────────────────────────
# OneSignal (SECRETS - keep in .env)
# ─────────────────────────────────────────────
ONESIGNAL_APP_ID=
ONESIGNAL_API_KEY=
# ─────────────────────────────────────────────
# VAPID (SECRETS - keep in .env)
# ─────────────────────────────────────────────
VAPID_SUBJECT=mailto:admin@yourdomain.com
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# ─────────────────────────────────────────────
# Idempotency & Circuit Breaker
# ─────────────────────────────────────────────
IDEMPOTENCY_KEY_TTL=86400
CIRCUIT_BREAKER_ERROR_RATE=0.3
CIRCUIT_BREAKER_MIN_REQUESTS=100
CIRCUIT_BREAKER_TIMEOUT=300000
PARTITION_RETAIN_MONTHS=12
# ─────────────────────────────────────────────
# Virus Scan (ClamAV)
# ─────────────────────────────────────────────
VIRUS_SCAN_ENABLED=true
VIRUS_SCAN_FAIL_OPEN=false
CLAM_HOST=127.0.0.1
CLAM_PORT=3310
CLAM_SOCKET=
CLAM_SCAN_TIMEOUT_MS=15000
# ─────────────────────────────────────────────
# Logging (Environment-specific)
# ─────────────────────────────────────────────
LOG_LEVEL=info
LOG_URL=./storage/logs
LOG_MODE=file
LOG_TIMEOUT=5000
# ─────────────────────────────────────────────
# CORS
# ─────────────────────────────────────────────
DEV_CORS_DISABLE=false
# ─────────────────────────────────────────────
# Debug
# ─────────────────────────────────────────────
LOG_CONFIG_SOURCES=false
module.exports = {
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'prettier',
],
plugins: ['@typescript-eslint'],
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
env: {
node: true,
es2022: true,
jest: true,
},
rules: {
// TypeScript specific rules
'@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/prefer-optional-chain': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/await-thenable': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/only-throw-error': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-base-to-string': 'off',
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
'@typescript-eslint/no-redundant-type-constituents': 'off',
'no-useless-escape': 'off',
'no-useless-catch': 'off',
// General rules
'no-console': 'off', // Allow console.log in backend
'no-debugger': 'error',
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: 'off',
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error',
'no-script-url': 'error',
'no-void': ['error', { allowAsStatement: true }],
'prefer-promise-reject-errors': 'error',
'require-await': 'off', // Handled by @typescript-eslint
// Code complexity rules (temporarily disabled)
complexity: 'off',
'max-depth': 'off',
'max-params': 'off',
'max-statements': 'off',
},
ignorePatterns: ['dist/', 'node_modules/', 'storage/', 'coverage/', '**/*.js'],
overrides: [
{
files: ['src/config/**/*.ts'],
rules: {
'no-restricted-syntax': 'off', // Allow process.env in config layer
},
},
{
files: ['src/**/*.ts'],
excludedFiles: ['src/config/**/*.ts', 'src/root.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="process"][property.name="env"]',
message:
'Use Config from #config instead of process.env directly. Environment variables should only be accessed through the config layer.',
},
],
},
},
{
files: ['**/*.test.ts', '**/*.spec.ts', 'tests/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': 'off',
'no-restricted-syntax': 'off', // Allow process.env in tests
},
},
{
files: ['src/middlewares/csrf.ts', 'src/middlewares/rate-limiter.ts'],
rules: {
'no-restricted-syntax': 'off', // Allow process.env.NODE_ENV for environment detection in security middleware
},
},
],
};
# Ensure husky hook scripts keep LF endings across platforms
.husky/* text eol=lf
# 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.*
!.env.example
# 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/
/data/
\ No newline at end of file
pnpm exec commitlint --config ./config/commitlint.config.js --edit "$1"
echo "🔍 Running pre-commit quality checks..."
# Run lint-staged first (includes linting and formatting)
echo "🎨 Running lint-staged..."
pnpm run lint-staged
if [ $? -ne 0 ]; then
echo "❌ Linting or formatting failed. Please fix the issues and try again."
exit 1
fi
# Type check to catch TS errors before committing
echo "🔎 Running TypeScript type-check..."
pnpm run type-check
if [ $? -ne 0 ]; then
echo "❌ TypeScript type-check failed. Please fix type errors before committing."
exit 1
fi
# Structure quality checks
echo ""
echo "🏗️ Running structure quality checks..."
# 1. Naming convention check
echo " 🔤 Checking naming conventions..."
node scripts/check-naming-convention.js
if [ $? -ne 0 ]; then
echo "❌ Naming convention check failed. Please fix the naming issues."
exit 1
fi
# 2. File size check
echo " 📏 Checking file sizes..."
node scripts/check-file-size.js
if [ $? -ne 0 ]; then
echo "❌ File size check failed. Please refactor large files."
exit 1
fi
# 3. Placeholder check
echo " 📝 Checking for placeholder files..."
node scripts/check-placeholder.js
if [ $? -ne 0 ]; then
echo "❌ Placeholder check failed. Please implement or remove placeholder files."
exit 1
fi
# 4. Empty folder check
echo " 📁 Checking for empty folders..."
node scripts/check-empty-folders.js
if [ $? -ne 0 ]; then
echo "❌ Empty folder check failed. Please remove or add files to empty folders."
exit 1
fi
# Security check (temporarily disabled - 12 vulnerabilities remain, reduced from 42)
# echo "🔒 Running security audit..."
# pnpm run security:audit
# if [ $? -ne 0 ]; then
# echo "❌ Security vulnerabilities found. Please fix them before committing."
# exit 1
# fi
echo ""
echo "✅ All pre-commit checks passed!"
echo "🚀 Running pre-push checks..."
# Run only critical tests (auth + security) — fast gate before push.
# Run `pnpm test:unit` or `pnpm test:coverage` locally for full coverage.
echo "🧪 Running critical tests (auth + security)..."
pnpm run test:critical
if [ $? -ne 0 ]; then
echo "❌ Critical tests failed. Please fix failing tests before pushing."
exit 1
fi
# Generate and validate OpenAPI spec to prevent breaking API contract
echo "🔧 Generating OpenAPI spec and validating..."
pnpm -s swagger:generate && pnpm -s swagger:check
if [ $? -ne 0 ]; then
echo "❌ OpenAPI generation/validation failed. Please fix swagger issues before pushing."
exit 1
fi
echo "✅ All pre-push checks passed!"
# Check if commit message is empty (rebase, merge, etc.)
if [ -z "$(head -n1 "$1")" ]; then
exit 0
fi
# Add conventional commit template if message is empty
if [ "$(head -n1 "$1")" = "" ]; then
echo "# Conventional Commits Guide:
# Types: feat, fix, docs, style, refactor, test, chore
# Format: type(scope): description
# Example: feat(auth): add JWT token validation
#
# Please write your commit message above this line" > "$1"
fi
# Dependencies
node_modules/
pnpm-lock.yaml
# Build outputs
dist/
build/
coverage/
# Logs
storage/logs/
*.log
# Environment files
.env
.env.local
.env.*.local
# Database
*.sqlite
*.sqlite3
*.db
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Temporary files
*.tmp
*.temp
# Package manager files
package-lock.json
yarn.lock
# Generated files
.docusaurus
.cache
.parcel-cache
# Auto-generated database models
src/models/
# Documentation build
docs/build/
# Testing
test-results/
playwright-report/
{
"arrowParens": "always",
"semi": true,
"trailingComma": "all",
"tabWidth": 2,
"useTabs": true,
"printWidth": 120,
"singleQuote": true,
"quoteProps": "as-needed",
"bracketSpacing": true,
"bracketSameLine": false,
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto",
"insertPragma": false,
"proseWrap": "preserve",
"requirePragma": false,
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": false,
"overrides": [
{
"files": "*.json",
"options": {
"tabWidth": 2,
"useTabs": false
}
},
{
"files": "*.md",
"options": {
"printWidth": 100,
"proseWrap": "always"
}
},
{
"files": "*.yml",
"options": {
"tabWidth": 2,
"useTabs": false
}
},
{
"files": "*.yaml",
"options": {
"tabWidth": 2,
"useTabs": false
}
}
]
}
# Authors
Nguyen Thi Nguyet Que <quentn0620@gmail.com> - Original Author and Creator
# Contributing to Backend Template
## Original Author
This project was originally created and is maintained by:
- **Nguyen Thi Nguyet Que** <quentn0620@gmail.com>
## How to Contribute
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Code Style
- Follow the existing coding conventions (see `docs/conventions.md`)
- Run `pnpm lint` and `pnpm type-check` before committing
- Use conventional commits format
## Questions?
For questions about the project, please contact the original author.
# ── Stage 1: base ────────────────────────────────────────────
# Shared foundation: system deps + pnpm + lockfile-only install
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
# ── Stage 2: build ───────────────────────────────────────────
# Compile TypeScript → dist/
FROM base AS build
COPY . .
RUN pnpm build
COPY src/templates ./dist/templates
# ── Stage 3: production-deps ─────────────────────────────────
# Prune to prod-only dependencies (no devDeps)
FROM base AS production-deps
RUN pnpm install --frozen-lockfile --prod
# ── Stage 4: development ─────────────────────────────────────
FROM base AS development
ENV NODE_ENV=development
RUN chown -R node:node /usr/src/app
USER node
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 ["pnpm", "dev"]
# ── Stage 5: production ──────────────────────────────────────
FROM node:20-alpine3.19 AS production
RUN apk add --no-cache dumb-init curl
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
WORKDIR /usr/src/app
ENV TZ="Asia/Ho_Chi_Minh"
ENV NODE_ENV=production
# Reuse pruned deps from production-deps stage (no re-install)
COPY --from=production-deps /usr/src/app/node_modules ./node_modules
COPY --from=production-deps /usr/src/app/package.json ./package.json
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 appuser:appgroup storage
USER appuser
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"]
# BeKind Backend
Backend template built with TypeScript, Node.js, Express.js and PostgreSQL. Features auto-routing,
Sequelize ORM, JWT authentication, Server-Sent Events (SSE), and OneSignal push notifications.
**Created by Nguyen Thi Nguyet Que**
## Quick Start
```bash
pnpm install
cp .env.example .env # fill in DB, Redis, JWT values — see docs/setup.md
psql -U postgres -c "CREATE DATABASE bekind;"
pnpm migrate
pnpm dev
```
Server: `http://localhost:3001` — Swagger UI: `http://localhost:3001/swagger/index`
---
## Important Notes
⚠️ **Database Schema Changes**: NEVER modify database structure directly (SQL console, GUI tools,
etc.). Always use migration files in `sql/migrations/`. In production, restrict schema changes via
PostgreSQL permissions:
```sql
REVOKE CREATE ON SCHEMA public FROM app_user;
REVOKE ALTER ON ALL TABLES IN SCHEMA public FROM app_user;
```
---
## Stack
| Layer | Technology |
| --------- | ------------------------------------- |
| Runtime | Node.js 20 + TypeScript |
| Framework | Express.js + express-automatic-routes |
| ORM | Sequelize (PostgreSQL) |
| Cache | Redis |
| Auth | JWT + HttpOnly Cookies / Bearer |
| Docs | OpenAPI 3.0 (Zod + zod-to-openapi) |
---
## Important Commands
### Development
| Command | Description |
| ----------------------------- | -------------------------------------- |
| `pnpm dev` / `pnpm start:dev` | Run development server with hot reload |
| `pnpm build` | Build for production |
| `pnpm start` | Run production server |
### Database
| Command | Description |
| -------------- | ----------------------------- |
| `pnpm migrate` | Run SQL migrations |
| `pnpm gen-db` | Generate models from database |
### Quality
| Command | Description |
| ----------------------- | -------------------------------------------------------- |
| `pnpm lint` | ESLint check |
| `pnpm type-check` | TypeScript check |
| `pnpm quality:check` | Full check — lint + type-check + swagger + test coverage |
| `pnpm swagger:validate` | Generate then validate Swagger spec |
### Testing
| Command | Description |
| ----------------------- | --------------------------------------- |
| `pnpm test:unit` | Unit tests only |
| `pnpm test:integration` | Integration tests only |
| `pnpm test:coverage` | Full suite + coverage report |
| `pnpm test:critical` | Critical gate — auth + security (< 30s) |
---
## Docs
| Topic | Link |
| ------------------- | -------------------------------------------------- |
| Setup & Environment | [docs/setup.md](docs/setup.md) |
| New API Development | [docs/api-development.md](docs/api-development.md) |
| Error Handling | [docs/error-handling.md](docs/error-handling.md) |
| Configuration | [docs/configuration.md](docs/configuration.md) |
| Swagger / OpenAPI | [docs/swagger.md](docs/swagger.md) |
| Architecture | [docs/architecture.md](docs/architecture.md) |
| Testing | [docs/testing.md](docs/testing.md) |
| Conventions | [docs/conventions.md](docs/conventions.md) |
| Security | [docs/security.md](docs/security.md) |
<!-- Test -->
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 = {
// Exclude auto-generated models from linting
'src/**/*.ts': (filenames) => {
const filtered = filenames.filter((f) => !f.startsWith('src/models/'));
if (filtered.length === 0) return [];
return ['pnpm run lint:fix', `prettier --write ${filtered.join(' ')}`];
},
'tests/**/*.ts': (filenames) => {
return ['pnpm run lint:fix', `prettier --write ${filenames.join(' ')}`];
},
'*.{js,jsx,ts,tsx,json,md,yml,yaml}': (filenames) => {
const filtered = filenames.filter((f) => !f.startsWith('src/models/'));
if (filtered.length === 0) return [];
return [`prettier --write ${filtered.join(' ')}`];
},
};
# ─────────────────────────────────────────────
# Infrastructure services (always-on, no profile)
# Run independently: docker compose up redis minio -d
# ─────────────────────────────────────────────
services:
redis:
image: redis:7-alpine
container_name: bekind-redis
restart: unless-stopped
ports:
- '${REDIS_PORT:-6379}:6379'
volumes:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- bekind-network
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 3
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
container_name: bekind-minio
restart: unless-stopped
ports:
- '${MINIO_PORT:-9000}:9000'
- '9001:9001'
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin123}
volumes:
- minio_data:/data
command: server /data --console-address ':9001'
networks:
- bekind-network
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 30s
timeout: 10s
retries: 3
# ─────────────────────────────────────────────
# Application (dev / staging / prod profile)
# Run: docker compose --profile dev up app-dev -d
# docker compose --profile staging up app-staging -d
# docker compose --profile prod up app-prod -d
# ─────────────────────────────────────────────
app-dev:
profiles: ['dev']
build:
context: .
dockerfile: Dockerfile
target: development
container_name: bekind-dev
restart: unless-stopped
ports:
- '3001:3001'
env_file:
- .env
environment:
- REDIS_HOST=redis
- MINIO_ENDPOINT=minio
- CHOKIDAR_USEPOLLING=true
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
networks:
- bekind-network
depends_on:
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3001/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
app-staging:
profiles: ['staging']
build:
context: .
dockerfile: Dockerfile
target: production
restart: unless-stopped
ports:
- '3002:3001'
env_file:
- .env.staging
environment:
- REDIS_HOST=redis
- MINIO_ENDPOINT=minio
networks:
- bekind-network
depends_on:
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3001/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
app-prod:
profiles: ['prod']
build:
context: .
dockerfile: Dockerfile
target: production
restart: unless-stopped
ports:
- '3003:3001'
env_file:
- .env.prod
environment:
- REDIS_HOST=redis
- MINIO_ENDPOINT=minio
networks:
- bekind-network
depends_on:
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3001/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
bekind-network:
driver: bridge
volumes:
redis_data:
minio_data:
This diff is collapsed.
# Architecture
Author: Nguyen Thi Nguyet Que Created on: May 2, 2026
## Overview
```
Request → Middleware → Controller → Service (optional) → Provider → Database
External APIs
```
Controllers are thin HTTP adapters. Business logic lives in Services. Data access lives in
Providers.
---
## Layers
### Controller
**Responsibilities:** Parse request → call Service or Provider → format response.
**Must not:** contain business logic, transactions, or external API calls.
```typescript
handler: async (req: Req, res: Res) => {
try {
const result = await ProductService.createWithInventory(req.body);
return res.sendOk({ data: result });
} catch (error) {
return res.error(error);
}
},
```
### Service
**Responsibilities:** Business logic, multi-table transactions, external API calls, multi-platform
handling.
**Must not:** import Express types (`req`, `res`) — Services are HTTP-agnostic.
```typescript
export class ProductService {
static async createWithInventory(data: CreateProductDTO) {
return sequelize.transaction(async (t) => {
const product = await Product.create(data, { transaction: t });
await Inventory.create({ product_id: product.id }, { transaction: t });
return product;
});
}
}
```
### Provider
**Responsibilities:** CRUD operations, queries, pagination/filter on a single model.
**Must not:** contain business logic, call external APIs, or coordinate across models.
```typescript
export class ProductProvider extends BaseProvider<Product> {
constructor() {
super('Product');
}
// Add complex queries as methods here
async findByPriceRange(min: number, max: number) {
return Product.findAll({ where: { price: { [Op.between]: [min, max] } } });
}
}
```
---
## Decision guide
| Scenario | Use |
| ---------------------------------- | ---------- |
| Simple CRUD on a single table | Provider |
| Transaction across multiple tables | Service |
| External API call | Service |
| Complex business logic | Service |
| Multi-platform logic (web/mobile) | Service |
| Query / filter / pagination | Provider |
| Response formatting | Controller |
---
## Response format
All responses follow this structure:
```json
{
"success": true,
"responseData": {},
"message": "Success",
"message_en": "Success",
"trace_id": "uuid-...",
"timeStamp": "2026-05-01 10:00:00",
"violations": null
}
```
In controllers:
```typescript
// Success
res.sendOk({ data: result, message: 'Created', message_en: 'Created' });
// Error — always pass Error or GenericError, never a plain object
res.error(error);
```
Access in client:
```typescript
if (response.success === true) {
const item = response.responseData; // your data
}
// Validation errors: response.violations[] — array of { field, message }
```
---
## Error handling
Use `GenericError` for all domain errors:
```typescript
import { GenericError } from '#interfaces/error/generic';
// Factory method (preferred)
throw GenericError.create({ vi: 'Không tìm thấy', en: 'Not found' }, 'NOT_FOUND', 404);
// With additional data
throw GenericError.create(
{ vi: 'Dữ liệu không hợp lệ', en: 'Invalid input' },
'VALIDATION_ERROR',
400,
{ errors: [{ field: 'email', message: 'Invalid email' }] },
);
```
**Common error codes:** `NOT_FOUND` · `UNAUTHORIZED` · `FORBIDDEN` · `VALIDATION_ERROR` · `CONFLICT`
· `TOO_MANY_REQUESTS` · `INTERNAL_ERROR`
---
## Routing
Controllers under `src/controllers/api/v1/` are mounted at `/api/v1/`.
| File | HTTP | Route |
| ---------------------------------------------- | ------ | ----------------------------- |
| `api/v1/product/index.ts``get` | GET | `/api/v1/product` |
| `api/v1/product/index.ts``post` | POST | `/api/v1/product` |
| `api/v1/product/{id}.ts``get` | GET | `/api/v1/product/:id` |
| `api/v1/product/{id}.ts``put` | PUT | `/api/v1/product/:id` |
| `api/v1/product/{id}.ts``delete` | DELETE | `/api/v1/product/:id` |
| `api/v1/product/{id}/publish/index.ts``put` | PUT | `/api/v1/product/:id/publish` |
Folder names use **singular kebab-case**: `user`, `role`, `file`, `role-permission`.
---
## Existing modules
```
src/controllers/api/v1/
├── auth/ # Login, register, refresh, logout
├── user/ # CRUD with role management
├── role/ # RBAC role management
├── permission/ # Permission definitions
├── role-permission/ # Role ↔ Permission mapping
├── user-role/ # User ↔ Role assignment
├── file/ # File upload and storage
└── notifications/ # Push, email, in-app notifications
```
Each module has: Controller · Service · Provider · Validator
---
## Common middleware
| Middleware | Purpose | Import path |
| ---------------------------- | ----------------------------- | ----------------------------- |
| `verify` | JWT authentication | `#middlewares/auth` |
| `detectPlatform` | Detect web / mobile / zalo | `#middlewares/platform` |
| `queryModifier` | Parse pagination/filter query | `#middlewares/query-modifier` |
| `createDistributedRateLimit` | Redis-backed rate limiting | `#middlewares/rateLimiter` |
| `validateId` | Validate UUID `req.params.id` | `#middlewares/validators` |
[VIETPRODEV] HƯỚNG DẪN VÀ QUY ĐỊNH VỀ CÁCH SỬ DỤNG GITLAB
Đây là hướng dẫn và quy định về cách sử dụng GitLab của công ty dành cho Developer. Nếu project bạn đang tham gia có quy định riêng thì thực hiện theo quy định của project, nếu không có thì bắt buộc tuân thủ các quy định dưới đây.
Lưu ý:
- Các bạn tham gia dự án cần sử dụng GitLab công ty (nếu chưa có tài khoản thì cung cấp họ tên và email cho mentor để nhận tài khoản)
- Khi làm dự án: Tất cả tên biến, tên hàm, tên nhánh, tên commit đều bắt buộc sử dụng tiếng Anh
Hướng dẫn
Clone dự án:
1. git clone "đường dẫn repo"
2. git checkout develop
3. git checkout -b "tên_nhánh_mới"
Sau khi hoàn thành task:
1. git add .
2. git commit -m "ghi chú thay đổi"
3. git push origin "tên_nhánh_mới"
Lưu ý:
- Nếu làm tính năng mới: "tên_nhánh_mới" bắt đầu bằng "feat/tên_tính_năng", "ghi chú thay đổi" có dạng "feat(tên_module): mô tả ngắn"
- Nếu fix bug: "tên_nhánh_mới" bắt đầu bằng "fix/tên_bug", "ghi chú thay đổi" có dạng "fix(tên_module): mô tả ngắn"
- Tham khảo thêm ở phần “Quy tắc” bên dưới
4. Lên trang gitlab tạo merge request (MR) để merge vào develop
5. Gửi MR cho maintainer
6. Sau khi maintainer đã merge code, thực hiện các lệnh sau:
+ git checkout develop
+ git pull origin develop
+ git checkout -b "tên_nhánh_mới" (khác với các nhánh đã tạo)
+ Thực hiện task mới
Quy tắc
Dưới đây là một số nguyên tắc chung và mẫu đặt tên commit để bạn có thể tham khảo, áp dụng cho dự án của mình:
1. Định dạng tiêu chuẩn (Conventional Commits)
Áp dụng cấu trúc:
<type>(<scope>): <subject>
type: Loại thay đổi, thường dùng:
feat: Thêm tính năng mới
fix: Sửa lỗi
doc: Thay đổi liên quan đến tài liệu
style: Định dạng code, khoảng trắng, dấu chấm phẩy… (không ảnh hưởng logic)
refactor: Tái cấu trúc code (không thêm tính năng, không sửa lỗi)
test: Thêm/sửa test
chore: Cấu hình, script, công cụ, task mà không ảnh hưởng vào src
scope (tuỳ chọn): Phạm vi module hoặc component (ví dụ auth, ui, api)
subject: Mô tả ngắn gọn (<= 50 ký tự), viết ở thì hiện tại, không viết hoa đầu câu và không có dấu chấm ở cuối.
Ví dụ:
feat(auth): add JWT refresh-token endpoint
fix(ui): correct button color on hover
docs: update CONTRIBUTING.md with code style rules
2. Ghi chú chi tiết trong phần body (tuỳ chọn)
Cách dùng: để thông tin bổ trợ, giải thích “why” (tại sao) chứ không chỉ “what” (cái gì).
Ngắt dòng sau subject bằng 1 dòng trắng, sau đó mỗi dòng tối đa ~72 ký tự.
fix(api): handle null user profile
Trước đây khi người dùng chưa cập nhật profile thì endpoint /profile
sẽ trả về lỗi 500. Bổ sung kiểm tra null và trả về đối tượng rỗng.
3. Tham chiếu issue / ticket
Nếu commit liên quan Issue trên GitHub hoặc ticket, thêm dòng footer:
fix(auth): prevent SQL injection
hoặc
feat(ui): implement dark mode toggle
4. Nguyên tắc chung
Viết ở thì hiện tại: “add feature” (✗ “added feature”, “adds feature”)
Ngắn gọn – rõ ý: <= 50 ký tự cho subject.
Không dùng dấu chấm câu ở cuối subject.
Imperative mood: treat it like giving lệnh (“fix bug”, “update docs”).
Không viết quá chung chung: tránh “update code”, “fix stuff” → hãy cụ thể là “fix null pointer exception in checkout”.
5. Ví dụ tổng hợp
feat(payment): support PayPal checkout flow
- Thêm endpoint /payment/paypal/create
- Cập nhật UI hiển thị nút PayPal
- Viết unit test cho service mới
Áp dụng nhất quán chuẩn commit message sẽ giúp lịch sử commit rõ ràng, dễ theo dõi và tự động hóa (semver, changelog generation…) thuận tiện hơn. Nếu dự án của bạn có quy ước riêng, hãy thêm ở CONTRIBUTING.md để cả team cùng tuân thủ.
# Configuration Guide
Author: Nguyen Thi Nguyet Que Created on: May 3, 2026 Updated: May 3, 2026 — Simplified
configuration documentation
## Quick Start
```bash
# 1. Copy environment template
cp .env.example .env
# 2. Edit .env with your settings
# - DB_HOST, DB_USER, DB_PASSWORD, DB_NAME
# - JWT_SECRET, TOKEN_ENCRYPTION_KEY (min 32 chars)
# 3. Run
pnpm dev
```
---
## ✅ A. Configuration Priority
1. **`.env`** (highest priority) - Secrets and deployment-specific values
2. **Environment config** (`development.ts`, `production.ts`) - Environment-specific behavior
3. **`base.ts`** (lowest priority) - Default values for all environments
Example:
```typescript
// base.ts: port = 3001
// .env: PORT = 5000
// Final: port = 5000 (from .env)
```
---
## 📁 B. File Structure
```
src/config/
├── schema.ts # Zod validation schema
├── base.ts # Default values (all environments)
├── development.ts # Development overrides (relaxed security)
├── production.ts # Production overrides (strict security)
└── index.ts # Merges config + loads .env
```
---
## 🎯 C. When to Edit Each File
| Task | File |
| ----------------------------------- | ----------------------- |
| Add new config option | `schema.ts` + `base.ts` |
| Change default for all environments | `base.ts` |
| Relax security for dev | `development.ts` |
| Tighten security for production | `production.ts` |
| Set secrets/passwords | `.env` |
---
## ⚙️ D. Common Configuration
### D1. Change Port
```bash
# .env
PORT=3001
```
### Database Connection
```bash
# .env
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_password
DB_NAME=bekind
```
### D3. Enable Redis
```bash
# .env
REDIS_ENABLED=true
REDIS_HOST=localhost
REDIS_PORT=6379
```
### D4. JWT Secrets
```bash
# .env (use strong secrets, min 32 chars)
JWT_SECRET=your_super_secret_key_minimum_32_characters
JWT_REFRESH_SECRET=your_refresh_secret_minimum_32_characters
TOKEN_ENCRYPTION_KEY=exactly_32_characters_long_key
```
### D5. Switch Storage Provider
```bash
# .env
STORAGE_PROVIDER=local # or 'minio'
```
---
## 🔄 E. Development vs Production
| Setting | Development | Production |
| ------------------------- | ----------- | ---------- |
| `bcryptRounds` | 4 | 12 |
| `includeTokensInResponse` | true | false |
| `forceSecure` | false | true |
| `csrf.originValidation` | false | true |
| `cors.devDisable` | true | false |
| `storage.provider` | local | minio |
| `redis.enabled` | false | true |
| `virusScan.enabled` | false | true |
| `showStackInErrors` | true | false |
## Add New Configuration
**F1. Add to schema.ts:**
```typescript
export const ConfigSchema = z.object({
// ... existing fields
myNewFeature: z.object({
enabled: z.boolean().default(false),
apiKey: z.string().optional(),
}),
});
```
**2. Add to base.ts:**
```typescript
export const baseConfig: Config = {
// ... existing fields
myNewFeature: {
enabled: false,
apiKey: '',
},
};
```
**F3. Add to .env.example:**
```bash
MY_NEW_FEATURE_ENABLED=false
MY_NEW_FEATURE_API_KEY=
```
**4. Add to index.ts (for env override):**
```typescript
myNewFeature: {
...merged.myNewFeature,
enabled: process.env.MY_NEW_FEATURE_ENABLED === 'true',
apiKey: process.env.MY_NEW_FEATURE_API_KEY || merged.myNewFeature.apiKey,
},
```
**F5. Override in environment-specific config (optional):**
```typescript
// development.ts
myNewFeature: {
enabled: true, // Enable by default in dev
}
```
---
## 🌍 G. Environment Selection
| NODE_ENV | Config File |
| ------------- | ---------------- |
| `development` | `development.ts` |
| `test` | `development.ts` |
| `staging` | `production.ts` |
| `production` | `production.ts` |
---
## 🔐 H. Security Rules
- **NEVER** commit `.env` to git
- Use `.env.example` as template
- Keep secrets only in `.env`
- Never hardcode secrets in TypeScript files
## Troubleshooting
### I1. Config not loading
- Check `NODE_ENV` is set
- Verify `.env` exists in project root
- Restart app after changing `.env`
### I2. Validation errors
- Check `schema.ts` for required fields
- Verify all env vars are set in `.env`
- Review Zod error message
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# Security
Author: Nguyen Thi Nguyet Que Created on: May 2, 2026
## Rules
- **Never return tokens in the response body** for `web` platform — use HttpOnly cookies only.
- **Never leak stack traces** — always use `res.error(error)`, never
`res.json({ error: error.stack })`.
- **Hash tokens before logging** — never log raw access/refresh tokens.
- **Encrypt tokens at rest** — tokens stored in the database must be encrypted.
- **Rate-limit all auth endpoints** — use `createDistributedRateLimit` (Redis-backed).
- **Validate the `X-Platform` header** on every authenticated endpoint (mobile/zalo flows depend on
it).
- **Parameterized queries only** — no raw string concatenation in SQL.
- **CORS `credentials: true`** with an explicit origin whitelist in production.
---
## Platform authentication
| Platform | Header | Token delivery | Notes |
| -------- | -------------------------- | ---------------- | ------------------------------------------ |
| `web` | `X-Platform: web` | HttpOnly cookies | Tokens **never** returned in response body |
| `mobile` | `X-Platform: mobile` | Response body | Requires `device_id` in the request |
| `zalo` | `X-Platform: zalo_miniapp` | Response body | Requires Zalo auth flow |
Detection priority: query param → header → User-Agent → default (`web`)
---
## Rate limiting example
```typescript
import { createDistributedRateLimit } from '#middlewares/rateLimiter';
// Apply to auth routes
middleware: [
createDistributedRateLimit({ windowMs: 15 * 60 * 1000, max: 10 }),
validateLogin,
],
```
---
## Dependency security
```bash
pnpm security:audit # Audit dependencies for known vulnerabilities
pnpm security:fix # Auto-fix vulnerabilities
pnpm security:check # Audit + lint in one step
```
# Setup Guide
Author: Nguyen Thi Nguyet Que Created on: May 2, 2026
## Prerequisites
- Node.js 20+
- PostgreSQL 14+
- Redis 6+
- pnpm
---
## 1. Install dependencies
```bash
pnpm install
```
---
## 2. Configure environment
```bash
cp .env.example .env
```
Minimum required values to fill in before running the app:
```env
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=bekind
REDIS_HOST=localhost
REDIS_PORT=6379
JWT_SECRET=minimum_32_characters_secret_here
JWT_REFRESH_SECRET=minimum_32_characters_secret_here
TOKEN_ENCRYPTION_KEY=exactly_32_characters_key_here!!
```
See `.env.example` for the full list (email, storage, notifications, virus scan, etc.).
### All environment variables
| Variable | Description | Required |
| ---------------------- | --------------------------------------- | ----------------- |
| `DB_HOST` | PostgreSQL host | Yes |
| `DB_PORT` | PostgreSQL port | Yes |
| `DB_NAME` | Database name | Yes |
| `DB_USER` | Database user | Yes |
| `DB_PASSWORD` | Database password | Yes |
| `REDIS_HOST` | Redis host (rate limiting, sessions) | Yes |
| `REDIS_PORT` | Redis port | Yes |
| `JWT_SECRET` | JWT secret, min 32 chars | Yes |
| `JWT_REFRESH_SECRET` | JWT refresh secret, min 32 chars | Yes |
| `TOKEN_ENCRYPTION_KEY` | Token encryption key, exactly 32 chars | Yes |
| `PORT` | Server port | No (default 3001) |
| `NODE_ENV` | Environment | No (development) |
| `BACKEND_URL` | Public URL (used in Swagger server URL) | No |
| `STORAGE_PROVIDER` | `local` or `minio` | No (local) |
---
## 3. Create database
PostgreSQL must be running before this step.
```bash
psql -U postgres -c "CREATE DATABASE bekind;"
```
---
## 4. Run migrations
```bash
pnpm migrate
```
To run a specific migration only:
```bash
node sql/scripts/run-003.js
```
---
## 5. Generate models
Only required when the database schema changes.
```bash
pnpm gen-db
```
| Command | Description |
| ------------------- | ------------------------------------- |
| `pnpm gen-db` | Generate models from database |
| `pnpm gen-db:force` | Generate and overwrite existing files |
| `pnpm gen-db:clean` | Delete old models and regenerate |
> Generated models in `src/models/` are auto-generated — do not edit them by hand.
---
## 6. Run development server
```bash
pnpm dev
```
Server: `http://localhost:3001` — Swagger UI: `http://localhost:3001/swagger/index`
---
## Docker
```bash
# Development
docker-compose up --build
# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
# Swagger / OpenAPI Guide
Author: Nguyen Thi Nguyet Que Created on: May 2, 2026 Updated: May 2, 2026 — Migrated to Zod-based
contracts
The project uses **Zod schemas** with `@asteasolutions/zod-to-openapi` to generate type-safe OpenAPI
3.0 specs. All API contracts are defined in `src/contracts/` and the spec is generated by
`scripts/generate/generate-openapi.ts`.
## Commands
| Command | Description |
| ----------------------- | --------------------------------------------------------------- |
| `pnpm swagger:generate` | Generate `storage/swagger/openapi.json` from Zod contracts |
| `pnpm swagger:diff` | Compare current spec against baseline (detect breaking changes) |
| `pnpm swagger:check` | Validate spec structure |
| `pnpm swagger:validate` | Generate then validate in one step |
| `pnpm swagger:ci` | Full CI pipeline: generate + validate + diff |
`swagger:check` runs inside `quality:check` and `ci:quality` — CI fails if the spec is broken.
---
## Architecture
```
src/contracts/
shared.ts ← ViolationSchema, ApiResponse<T> composer
auth.schema.ts ← Zod schemas (request/response types)
auth.paths.ts ← registerPath() definitions
user.schema.ts
user.paths.ts
file.schema.ts
file.paths.ts
notification.schema.ts
notification.paths.ts
scripts/
generate/
generate-openapi.ts ← Orchestrator that calls all registerPath() functions
storage/swagger/
openapi.json ← Generated, committed (Orval uses this)
openapi.baseline.json ← Baseline for oasdiff breaking change detection
```
**Key principles:**
- Zod schemas define the shape and validation
- `zod-to-openapi` generates the OpenAPI spec automatically
- No JSDoc `@openapi` comments in controllers
- No hand-written `schemas.js` files
- Type safety enforced by `satisfies` keyword in controllers
---
## Adding a new API endpoint
### 1. Define Zod schemas in `src/contracts/<domain>.schema.ts`
```typescript
// src/contracts/product.schema.ts
import { z } from 'zod';
import { ApiResponse } from './shared';
export const ProductSchema = z
.object({
id: z.uuid(),
name: z.string().min(1).max(200),
price: z.number().positive(),
created_at: z.iso.datetime(),
})
.openapi('Product');
export const CreateProductBodySchema = z
.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
})
.openapi('CreateProductBody');
export const ProductResponseSchema = ApiResponse(
z.object({
product: ProductSchema,
}),
).openapi('ProductResponse');
export type Product = z.infer<typeof ProductSchema>;
export type CreateProductBody = z.infer<typeof CreateProductBodySchema>;
export type ProductResponse = z.infer<typeof ProductResponseSchema>;
```
### 2. Register the path in `src/contracts/<domain>.paths.ts`
```typescript
// src/contracts/product.paths.ts
import { type OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { CreateProductBodySchema, ProductResponseSchema } from './product.schema';
export function registerProductPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'post',
path: '/api/v1/products',
operationId: 'createProduct',
tags: ['Products'],
request: {
body: { content: { 'application/json': { schema: CreateProductBodySchema } } },
},
responses: {
200: {
description: 'Product created successfully',
content: { 'application/json': { schema: ProductResponseSchema } },
},
},
});
// Add more paths here...
}
```
### 3. Add to `lib/generator/openapi/generate-openapi.ts`
```typescript
import { registerProductPaths } from '../src/contracts/product.paths';
// ... inside the script
registerProductPaths(registry);
```
### 4. Use in controller with `satisfies` for type safety
```typescript
import type { ProductResponse } from '#contracts/product.schema';
// In controller handler
const payload = {
success: true,
message: null,
message_en: null,
trace_id: requestId,
timeStamp: new Date().toISOString(),
violations: null,
responseData: {
product: createdProduct,
},
} satisfies ProductResponse; // ← Compile-time check
return res.json(payload);
```
### 5. Generate and validate
```bash
pnpm swagger:generate
pnpm swagger:check
```
---
## Breaking change detection
The project uses `oasdiff` to detect breaking changes between the current spec and the baseline:
```bash
pnpm swagger:diff
```
**Baseline policy:** When `swagger:diff` fails, the PR must include:
1. FE impacted list — which hooks/types are affected
2. Migration note — what FE needs to do
3. `openapi.baseline.json` update in the same PR
4. PR title with `BREAKING:` prefix
---
## Using orval to generate frontend client code
After `pnpm swagger:validate`:
```typescript
// orval.config.ts (frontend project)
export default defineConfig({
api: {
input: 'http://localhost:3001/openapi.json',
output: {
mode: 'tags-split',
target: 'src/api',
schemas: 'src/api/models',
client: 'react-query',
},
},
});
```
```bash
pnpm orval
```
The FE should run `pnpm orval` in CI and fail if generated changes are uncommitted.
---
## Available contracts
| Domain | Schema file | Paths file |
| ------------ | ------------------------ | ----------------------- |
| Auth | `auth.schema.ts` | `auth.paths.ts` |
| User | `user.schema.ts` | `user.paths.ts` |
| File | `file.schema.ts` | `file.paths.ts` |
| Notification | `notification.schema.ts` | `notification.paths.ts` |
| Shared | `shared.ts` | — |
---
## Common patterns
### Response envelope
All responses use the `ApiResponse<T>` composer from `shared.ts`:
```typescript
export const ApiResponse = <T extends ZodTypeAny>(data: T) =>
z.object({
success: z.boolean(),
message: z.string().nullable(),
message_en: z.string().nullable(),
trace_id: z.string().nullable(),
timeStamp: z.string(),
violations: z.array(ViolationSchema).nullable(),
responseData: data.nullable(),
});
```
### Validation middleware
Use Zod schemas for request validation:
```typescript
// src/middlewares/validators/product.ts
import { CreateProductBodySchema } from '#contracts/product.schema';
export const validateCreateProduct = zodMiddleware({ body: CreateProductBodySchema });
```
### DTO mapping for security
To prevent leaking internal fields, parse with the schema before returning:
```typescript
const userDto = UserPublicSchema.parse(result.user); // throws if shape invalid
```
# Testing Guide
Author: Nguyen Thi Nguyet Que Created on: May 2, 2026
## Policy
Test any behavior-changing code: new features, bug fixes, business logic updates.
Skip tests for: typo fixes, log message changes, swagger docs, renames, pure refactors.
---
## Test levels
| Level | What to test | Location |
| ----------- | ---------------------------------------- | ----------------------------------------- |
| Unit | Services, utilities, validators | `tests/unit/` |
| Integration | Routes, controllers, DB flows end-to-end | `tests/integration/` |
| Critical | Auth, permissions, file upload security | `tests/unit/services/auth*`, `virusScan*` |
**Rules:**
- Always unit test: Services with business logic, utility functions, validators
- Always integration test: Auth flows, file upload, permission checks
- Skip unit testing controllers — they thin-wrap services; use integration tests for routes instead
---
## Commands
```bash
pnpm test:unit # Unit tests only
pnpm test:integration # Integration tests only
pnpm test:coverage # Full suite + coverage report
pnpm test:critical # Critical gate — auth + security (< 30s)
pnpm test:watch # Watch mode during development
```
---
## Coverage thresholds
| Scope | Threshold |
| ------------------------------ | --------- |
| Global | ≥ 80% |
| Critical modules (auth, files) | ≥ 90% |
Prefer **meaningful assertions** over hitting coverage numbers. Avoid `expect(true).toBe(true)`
branch coverage and edge cases matter more than line %.
---
## Pre-push enforcement
```
git commit → lint + typecheck (fast, < 5s)
git push → pnpm test:critical (auth + security only, < 30s)
CI pipeline → full suite + coverage thresholds
```
`git push` is blocked only if critical tests fail. Full coverage runs in CI, not locally on every
push.
---
## Writing tests
### Unit test example (service)
```typescript
// tests/unit/services/productService.test.ts
import { ProductService } from '#services/productService';
describe('ProductService', () => {
it('should throw NOT_FOUND when product does not exist', async () => {
await expect(ProductService.getById('non-existent-id')).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
});
```
### Integration test example (route)
```typescript
// tests/integration/product.test.ts
import request from 'supertest';
import app from '#/app';
describe('GET /api/v1/product', () => {
it('returns 401 without auth token', async () => {
const res = await request(app).get('/api/v1/product');
expect(res.status).toBe(401);
});
});
```
---
See `tests/README.md` for the full testing guide including factory helpers and mock setup.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# Bulk create — Link files to Contract and Incident
Mục đích
- Cho phép liên kết nhiều file đã upload với một `contract_id` hoặc `incident_id` trong một lần gọi API.
Endpoints
- `POST /api/v1/contract-files` — Link nhiều file với một hợp đồng.
- `POST /api/v1/incident-files` — Link nhiều file với một sự cố.
Xác thực & quyền
- `POST /api/v1/contract-files`: yêu cầu `Bearer` token (middleware: `verify`).
- `POST /api/v1/incident-files`: yêu cầu `Bearer` token và quyền Admin (middleware: `verify`, `requireAdmin`).
Request body (JSON)
- Contract:
- `contract_id` (string, uuid) — Bắt buộc.
- `file_ids` (array[string, uuid]) — Bắt buộc, tối thiểu 1 phần tử.
- Incident:
- `incident_id` (string) — Bắt buộc.
- `file_ids` (array[string]) — Bắt buộc, tối thiểu 1 phần tử.
Ví dụ request — Contract
```json
{
"contract_id": "9fbbf51b-50a3-1aea-baa0-18596c0eebdf/i",
"file_ids": [
"9d9f1450-dd41-79d9-31f4-5b3903429ac3/i",
"f47ac10b-58cc-4372-a567-0e02b2c3d480/i"
]
}
```
Ví dụ request — Incident
```json
{
"incident_id": "9fbbf51b-50a3-1aea-baa0-18596c0eebdf/i",
"file_ids": [
"9d9f1450-dd41-79d9-31f4-5b3903429ac3/i"
]
}
```
Hành vi (Behavior)
- Với mỗi `file_id` trong `file_ids`, API sẽ tạo một bản ghi `ContractFile` hoặc `IncidentFile` tương ứng.
- Trường `created_by` được gán từ `req.user?.id` (người gọi API).
- Toàn bộ thao tác được thực hiện bằng `bulkCreate(...)` trong provider tương ứng (`ContractFileProvider.bulkCreate` hoặc `IncidentFileProvider.bulkCreate`) và chạy trong giao dịch — nếu một phần thất bại thì rollback cả nhóm (atomic).
- Controller trả về mảng các bản ghi vừa tạo (HTTP 201 cho contract-files, HTTP 200 cho incident-files theo controller hiện tại).
Ví dụ response
```json
{
"data": [
{
"id": "bb5ea6d5-fcd7-d0ca-d7ad-8f25a94bedd3",
"contract_id": "9fbbf51b-50a3-1aea-baa0-18596c0eebdf/i",
"file_id": "9d9f1450-dd41-79d9-31f4-5b3903429ac3/i",
"created_by": "user-uuid",
"created_at": "2026-05-18T12:34:56.000Z",
"updated_at": null,
"deleted_at": null
}
]
}
```
Schema validator & controller
- Contract schema: [src/contracts/contract-file/schema.ts](src/contracts/contract-file/schema.ts)
- Contract controller: [src/controllers/api/v1/contract-files/index.ts](src/controllers/api/v1/contract-files/index.ts)
- Incident schema: [src/contracts/incident-file/schema.ts](src/contracts/incident-file/schema.ts)
- Incident controller: [src/controllers/api/v1/incident-files/index.ts](src/controllers/api/v1/incident-files/index.ts)
Lưu ý & khuyến nghị
- `file_ids` là trường bắt buộc; `file_id` (đơn lẻ) không còn được dùng.
- Nếu cần tránh bản ghi trùng (duplicate) nên thực hiện dedupe trước khi gọi API hoặc thêm kiểm tra trong provider.
- Nếu database có ràng buộc khóa ngoại (FK) hoặc ràng buộc unique, lỗi có thể xuất hiện và toàn giao dịch sẽ rollback.
- Nếu muốn thay đổi mã trạng thái trả về cho consistency, cân nhắc trả `201 Created` cho cả hai endpoints.
Hành động tiếp theo (tuỳ chọn)
- Dọn dẹp: xóa hai file tài liệu cũ `contract-files-bulk-create.md``incident-files-bulk-create.md` nếu không cần giữ bản riêng.
- Commit thay đổi tài liệu vào repo.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
{
"watch": ["src"],
"ext": "ts,mjs,json",
"exec": "cross-env NODE_ENV=development tsx src/index.ts",
"env": {
"NODE_ENV": "development"
},
"stdout": true,
"stderr": true,
"legacyWatch": true,
"delay": "3000",
"killDelay": 10000
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment