Commit be088785 authored by ThinhNC's avatar ThinhNC

Merge branch 'develop' into 'master'

Develop

See merge request !1
parents 8031506c 0f6ec0c8
node_modules
dist
.env
.env.local
.DS_Store
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage
.vscode
.idea
*.log
NODE_ENV=development
PORT=7777
DATABASE_URL="postgresql://postgres:[PASSWORD]@localhost:5432/datafinwise?schema=public"
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=[PASSWORD]
DB_NAME=datafinwise
JWT_ACCESS_SECRET=change_me_access_secret
JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=redis
REDIS_PORT=6381
\ No newline at end of file
node_modules/
dist/
.env
*.log
storage/exports/*
!storage/exports/.gitkeep
.DS_Store
shamefully-hoist=true
# finwise-miniapp-be
<<<<<<< README.md
# FinWise BE - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App)
Backend API service for FinWise mini-app. Hiện tại backend cung cấp:
- Xác thực người dùng JWT
- Quản lý tài khoản người dùng
- Health check hệ thống
## Tech Stack
- **Node.js** & **Express** with **TypeScript**
- **Prisma ORM** with **PostgreSQL** (Supabase)
- **JWT Authentication** & **Role-Based Access Control**
- **Zod** for schema validation
---
## Core Models
Dựa trên Prisma schema hiện tại, repository chứa các model chính sau:
- **User**: Email, mật khẩu, tên, trạng thái và role.
- **Wallet**: Ví người dùng.
- **Category**: Danh mục thu/chi.
- **Transaction**: Giao dịch thu/chi.
- **Budget**: Ngân sách theo danh mục.
- **RefreshToken**: Lưu refresh token cho đăng nhập lâu dài.
> Lưu ý: hiện tại các route public/active trong project chỉ bao gồm Auth và Users.
---
## Getting Started
### Prerequisites
- Node.js (v18+)
- pnpm (recommended) or npm
### Installation
1. Clone the repository.
2. Install dependencies:
```bash
pnpm install
```
3. Set up your environment file:
- Copy `.env.example` to `.env`
- Fill in your connection strings and JWT keys:
```env
DATABASE_URL=your_supabase_pooler_url
DIRECT_URL=your_supabase_direct_url
JWT_ACCESS_SECRET=your_access_secret
JWT_REFRESH_SECRET=your_refresh_secret
```
### Database Initialization
Apply migrations and run the seeding script:
```bash
# Validate Prisma schema
npx prisma validate
# Run migrations
npx prisma migrate dev --name init_finwise
# Seed initial data
npm run db:seed
```
_Default Admin credentials:_
- **Email**: `admin@finwise.local`
- **Password**: `Admin@123456`
### Running the App
Start the development server:
```bash
npm run dev
```
The server runs on http://localhost:3000 by default.
Swagger UI is available at `http://localhost:3000/api/docs`.
---
## API Endpoints
- **System**:
- `GET /api/v1/health`: Health check.
- **Auth** (`/api/v1/auth`):
- `POST /login`: Authenticate user and receive access/refresh tokens.
- `GET /me`: Get current authenticated user details.
- `POST /refresh`: Refresh JWT tokens.
- `POST /logout`: Invalidate refresh token.
- **Users** (`/api/v1/users`) (Admin only):
- `GET /`: Get all users with optional filters, sorting, and pagination.
- `GET /:id`: Get user details by ID.
- `POST /`: Create a new user account.
- `PUT /:id`: Update user status or role.
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: finwise_postgres
environment:
POSTGRES_USER: "${DB_USER:-postgres}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-postgres}"
POSTGRES_DB: "${DB_NAME:-datafinwise}"
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: finwise_redis
ports:
- "${REDIS_PORT:-6381}:6379"
# app:
# build:
# context: .
# dockerfile: Dockerfile
# container_name: finwise_app
# env_file:
# - .env
# environment:
# NODE_ENV: "${NODE_ENV:-development}"
# PORT: "${PORT:-8888}"
# DB_HOST: postgres
# DB_PORT: "5432"
# DB_USER: "${DB_USER:-postgres}"
# DB_PASSWORD: "${DB_PASSWORD:-postgres}"
# DB_NAME: "${DB_NAME:-datafinwise}"
# DATABASE_URL: "postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-datafinwise}?schema=public"
# JWT_ACCESS_SECRET: "${JWT_ACCESS_SECRET:-default_access_secret}"
# JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET:-default_refresh_secret}"
# JWT_ACCESS_EXPIRES_IN: "${JWT_ACCESS_EXPIRES_IN:-1d}"
# JWT_REFRESH_EXPIRES_IN: "${JWT_REFRESH_EXPIRES_IN:-7d}"
# REDIS_HOST: redis
# REDIS_PORT: "6379"
# volumes:
# - ./:/usr/src/app
# - /usr/src/app/node_modules
# ports:
# - "${PORT:-8888}:${PORT:-8888}"
# depends_on:
# - postgres
# - redis
# command: sh -c "corepack enable && pnpm install && pnpm dev"
volumes:
postgres_data:
{
"name": "finwise-miniapp-be",
"version": "1.0.0",
"description": "Backend API service for FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App)",
"main": "dist/server.js",
"packageManager": "pnpm@9.15.0",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"prisma:generate": "node scripts/prisma-run.js generate",
"prisma:studio": "node scripts/prisma-run.js studio",
"db:migrate": "node scripts/prisma-run.js migrate dev",
"db:migrate:init": "node scripts/prisma-run.js migrate dev --name init",
"db:migrate:deploy": "node scripts/prisma-run.js migrate deploy",
"db:migrate:reset": "node scripts/prisma-run.js migrate reset",
"db:migrate:status": "node scripts/prisma-run.js migrate status",
"db:seed": "node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.9",
"@types/node": "^22.10.2",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
This diff is collapsed.
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('INCOME', 'EXPENSE');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"full_name" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"role_id" UUID NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallets" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"balance" DOUBLE PRECISION NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "transactions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"wallet_id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"type" "TransactionType" NOT NULL,
"description" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "budgets" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"start_date" TIMESTAMP(3) NOT NULL,
"end_date" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "budgets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" UUID NOT NULL,
"token" TEXT NOT NULL,
"user_id" UUID NOT NULL,
"user_agent" TEXT,
"ip_address" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "wallets_user_id_name_key" ON "wallets"("user_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "categories_user_id_name_type_key" ON "categories"("user_id", "name", "type");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_wallet_id_fkey" FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
\ No newline at end of file
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum TransactionType {
INCOME
EXPENSE
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
password String
fullName String? @map("full_name")
isActive Boolean @default(true) @map("is_active")
roleId String @map("role_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
wallets Wallet[]
categories Category[]
transactions Transaction[]
budgets Budget[]
refreshTokens RefreshToken[]
@@map("users")
}
model Role {
id String @id @default(uuid()) @db.Uuid
name String @unique
users User[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("roles")
}
model Wallet {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String
balance Float
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions Transaction[]
@@unique([userId, name])
@@map("wallets")
}
model Category {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String
type TransactionType
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions Transaction[]
budgets Budget[]
@@unique([userId, name, type])
@@map("categories")
}
model Transaction {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
walletId String @map("wallet_id") @db.Uuid
categoryId String @map("category_id") @db.Uuid
amount Float
type TransactionType
description String?
date DateTime
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
@@map("transactions")
}
model Budget {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
categoryId String @map("category_id") @db.Uuid
name String
amount Float
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@map("budgets")
}
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
token String @unique
userId String @map("user_id") @db.Uuid
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("refresh_tokens")
}
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
const roles = ['ADMIN', 'LEADER', 'INTERN'];
const roleMap: Record<string, string> = {};
for (const roleName of roles) {
const role = await prisma.role.upsert({
where: { name: roleName },
update: {},
create: { name: roleName },
});
roleMap[roleName] = role.id;
console.log(`Role ${roleName} upserted with ID ${role.id}`);
}
const adminEmail = 'admin@finwise.local';
const adminPassword = await bcrypt.hash('Admin@123456', 10);
const leaderEmail = 'leader@finwise.local';
const leaderPassword = await bcrypt.hash('Leader@123456', 10);
const internEmail = 'intern@finwise.local';
const internPassword = await bcrypt.hash('Intern@123456', 10);
await prisma.user.upsert({
where: { email: adminEmail },
update: {
password: adminPassword,
fullName: 'Admin',
roleId: roleMap['ADMIN'],
isActive: true,
},
create: {
email: adminEmail,
password: adminPassword,
fullName: 'Admin',
roleId: roleMap['ADMIN'],
isActive: true,
},
});
await prisma.user.upsert({
where: { email: leaderEmail },
update: {
password: leaderPassword,
fullName: 'Leader',
roleId: roleMap['LEADER'],
isActive: true,
},
create: {
email: leaderEmail,
password: leaderPassword,
fullName: 'Leader',
roleId: roleMap['LEADER'],
isActive: true,
},
});
await prisma.user.upsert({
where: { email: internEmail },
update: {
password: internPassword,
fullName: 'Intern',
roleId: roleMap['INTERN'],
isActive: true,
},
create: {
email: internEmail,
password: internPassword,
fullName: 'Intern',
roleId: roleMap['INTERN'],
isActive: true,
},
});
console.log('Seed completed successfully');
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
require('dotenv').config();
const { spawn } = require('child_process');
if (!process.env.DATABASE_URL) {
const password = encodeURIComponent(process.env.DB_PASSWORD || '');
const user = encodeURIComponent(process.env.DB_USER || 'postgres');
process.env.DATABASE_URL = `postgresql://${user}:${password}@${process.env.DB_HOST || 'localhost'}:${process.env.DB_PORT || '5432'}/${process.env.DB_NAME || 'datacrawler'}?schema=public`;
}
const args = process.argv.slice(2);
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(cmd, ['prisma', ...args], {
stdio: 'inherit',
env: process.env,
shell: true,
});
child.on('exit', (code) => process.exit(code ?? 1));
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import swaggerUi from 'swagger-ui-express';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes';
import { swaggerSpec, swaggerOptions } from './config/swagger.config';
import { rateLimitMiddleware } from './middlewares/rate-limit.middleware';
const app = express();
app.set('trust proxy', true);
app.use(helmet({ contentSecurityPolicy: false, }),);
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
app.use('/api/v1', rateLimitMiddleware, routes);
app.use(notFoundMiddleware);
app.use(errorMiddleware);
export default app;
export const EXPORT_TYPE = {
JSON: 'JSON',
CSV: 'CSV',
XLSX: 'XLSX',
MARKDOWN: 'MARKDOWN',
MARKDOWN_ZIP: 'MARKDOWN_ZIP',
FULL_ZIP: 'FULL_ZIP',
} as const;
export type ExportType = keyof typeof EXPORT_TYPE;
export const JOB_STATUS = {
PENDING: 'PENDING',
RUNNING: 'RUNNING',
COMPLETED: 'COMPLETED',
PARTIAL_COMPLETED: 'PARTIAL_COMPLETED',
FAILED: 'FAILED',
CANCELED: 'CANCELED',
BLOCKED: 'BLOCKED',
} as const;
export type JobStatus = keyof typeof JOB_STATUS;
export const ROLES = {
ADMIN: 'ADMIN',
CRAWLER_USER: 'CRAWLER_USER',
VIEWER: 'VIEWER',
} as const;
export type Role = keyof typeof ROLES;
import { ErrorCode } from './error-code';
export class AppError extends Error {
public readonly statusCode: number;
public readonly code?: ErrorCode;
public readonly isOperational: boolean;
constructor(message: string, statusCode: number = 500, code?: ErrorCode) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
Object.setPrototypeOf(this, new.target.prototype);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
export const ERROR_CODE = {
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
USER_INACTIVE: 'USER_INACTIVE',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
TOKEN_INVALID: 'TOKEN_INVALID',
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY',
CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND',
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
INVALID_URL: 'INVALID_URL',
} as const;
export type ErrorCode = keyof typeof ERROR_CODE;
import path from 'path';
import fs from 'fs';
export function ensureDirExists(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
export function buildExportFilePath(exportDir: string, fileName: string): string {
return path.join(exportDir, fileName);
}
export function getFileSizeBytes(filePath: string): number {
try {
const stat = fs.statSync(filePath);
return stat.size;
} catch {
return 0;
}
}
export function deleteFile(filePath: string): void {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch {
// ignore
}
}
export function toSlug(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function generateExportFileName(jobId: string, exportType: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const ext = getExtensionByType(exportType);
return `export-${jobId.slice(0, 8)}-${timestamp}.${ext}`;
}
function getExtensionByType(exportType: string): string {
const map: Record<string, string> = {
JSON: 'json',
CSV: 'csv',
XLSX: 'xlsx',
MARKDOWN: 'md',
MARKDOWN_ZIP: 'zip',
FULL_ZIP: 'zip',
};
return map[exportType] || 'bin';
}
import { AppError } from '../errors/app-error';
import { ERROR_CODE } from '../errors/error-code';
const PRIVATE_IP_PATTERNS = [
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^::1$/,
/^fd[0-9a-f]{2}:/i,
];
export function validateUrl(url: string): URL {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new AppError('Invalid URL format', 400, ERROR_CODE.INVALID_URL);
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new AppError('Only HTTP and HTTPS URLs are allowed', 400, ERROR_CODE.INVALID_URL);
}
const hostname = parsed.hostname;
for (const pattern of PRIVATE_IP_PATTERNS) {
if (pattern.test(hostname)) {
throw new AppError(
'Private or local IP addresses are not allowed',
403,
ERROR_CODE.PRIVATE_IP_BLOCKED,
);
}
}
return parsed;
}
export function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return '';
}
}
declare global {
namespace Express {
interface Request {
user: {
id: string;
email: string;
role: string;
};
}
}
}
export {};
import { envConfig } from './env.config';
export const databaseConfig = {
url: envConfig.databaseUrl,
host: envConfig.database.host,
port: envConfig.database.port,
user: envConfig.database.user,
password: envConfig.database.password,
name: envConfig.database.name,
};
export default databaseConfig;
export const envConfig = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '8888', 10),
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
name: process.env.DB_NAME || 'datacrawler',
},
get databaseUrl() {
return `postgresql://${this.database.user}:${encodeURIComponent(this.database.password)}@${this.database.host}:${this.database.port}/${this.database.name}?schema=public`;
},
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET || 'default_access_secret',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default_refresh_secret',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '1d',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
};
import { envConfig } from './env.config';
export const jwtConfig = envConfig.jwt;
This diff is collapsed.
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error', 'warn'],
});
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { jwtConfig } from '../config/jwt.config';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
next(new AppError('Unauthorized', 401, ERROR_CODE.UNAUTHORIZED));
return;
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, jwtConfig.accessSecret) as {
id: string;
email: string;
role: string;
};
req.user = {
id: payload.id,
email: payload.email,
role: payload.role as any,
};
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
next(new AppError('Token expired', 401, ERROR_CODE.TOKEN_EXPIRED));
} else {
next(new AppError('Invalid token', 401, ERROR_CODE.TOKEN_INVALID));
}
}
}
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
export function notFoundMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
next(new AppError(`Route ${req.method} ${req.originalUrl} not found`, 404, ERROR_CODE.NOT_FOUND));
}
export function errorMiddleware(
error: Error,
req: Request,
res: Response,
next: NextFunction,
): void {
if (error instanceof AppError) {
res.status(error.statusCode).json({
success: false,
message: error.message,
code: error.code,
});
return;
}
console.error('[Unhandled Error]', error);
res.status(500).json({
success: false,
message: 'Internal server error',
code: 'INTERNAL_SERVER_ERROR',
});
}
import { Request, Response, NextFunction } from 'express';
const requestCounts = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 15 * 60 * 1000;
const MAX_REQUESTS = 1000;
export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const record = requestCounts.get(ip);
if (!record || now > record.resetAt) {
requestCounts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
next();
return;
}
record.count += 1;
if (record.count > MAX_REQUESTS) {
res.status(429).json({
success: false,
message: 'Too many requests, please try again later',
code: 'RATE_LIMIT_EXCEEDED',
});
return;
}
next();
}
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
export function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
next(new AppError('Unauthorized', 401, ERROR_CODE.UNAUTHORIZED));
return;
}
if (!roles.includes(req.user.role)) {
next(new AppError('Forbidden', 403, ERROR_CODE.FORBIDDEN));
return;
}
next();
};
}
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
type ValidateTarget = 'body' | 'query';
export function validate(schema: ZodSchema, target: ValidateTarget = 'body') {
return (req: Request, res: Response, next: NextFunction): void => {
const input = target === 'query' ? req.query : req.body;
const result = schema.safeParse(input);
if (!result.success) {
const messages = result.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ');
next(new AppError(messages, 422, ERROR_CODE.VALIDATION_ERROR));
return;
}
if (target === 'query') {
req.query = result.data;
} else {
req.body = result.data;
}
next();
};
}
import { Request, Response, NextFunction } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './auth.dto';
export class AuthController {
private readonly service = new AuthService();
login = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as LoginDto;
const userAgent = req.headers['user-agent'];
const ipAddress = req.ip;
const result = await this.service.login(body, { userAgent, ipAddress });
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
me = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.getMe(req.user.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
refresh = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
const userAgent = req.headers['user-agent'];
const ipAddress = req.ip;
const result = await this.service.refresh(refreshToken, { userAgent, ipAddress });
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
logout = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
await this.service.logout(refreshToken);
res.json({
success: true,
message: 'Logged out successfully',
});
} catch (error) {
next(error);
}
};
}
export interface LoginDto {
email: string;
password: string;
}
export interface AuthTokensDto {
accessToken: string;
refreshToken: string;
}
export interface MeDto {
id: string;
email: string;
fullName: string | null;
role: string;
isActive: boolean;
createdAt: Date;
}
export interface LoginResponseDto {
accessToken: string;
refreshToken: string;
user: {
id: string;
email: string;
fullName: string | null;
role: string;
};
}
import { prisma } from '../../database/prisma.client';
export class AuthRepository {
findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
include: { role: true },
});
}
findById(id: string) {
return prisma.user.findUnique({
where: { id },
include: { role: true },
});
}
async saveRefreshToken(userId: string, token: string, expiresAt: Date, userAgent?: string, ipAddress?: string) {
return prisma.refreshToken.create({
data: {
userId,
token,
expiresAt,
userAgent,
ipAddress,
},
});
}
async findRefreshToken(token: string) {
return prisma.refreshToken.findUnique({
where: { token },
});
}
async deleteRefreshToken(token: string) {
return prisma.refreshToken.deleteMany({
where: { token },
});
}
}
import { Router } from 'express';
import { AuthController } from './auth.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { loginSchema, refreshSchema, logoutSchema } from './auth.validation';
const router = Router();
const controller = new AuthController();
router.post('/login', validate(loginSchema), controller.login);
router.get('/me', authMiddleware, controller.me);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(logoutSchema), controller.logout);
export default router;
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { AuthRepository } from './auth.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto } from './auth.dto';
export class AuthService {
private readonly repository = new AuthRepository();
async login(data: LoginDto, metadata?: { userAgent?: string; ipAddress?: string }): Promise<LoginResponseDto> {
const { email, password } = data;
const user = await this.repository.findByEmail(email);
if (!user) {
throw new AppError('Invalid credentials', 401, ERROR_CODE.INVALID_CREDENTIALS);
}
if (!user.isActive) {
throw new AppError('Account is inactive', 403, ERROR_CODE.USER_INACTIVE);
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new AppError('Invalid credentials', 401, ERROR_CODE.INVALID_CREDENTIALS);
}
const payload = { id: user.id, email: user.email, role: user.role.name };
const accessToken = jwt.sign(payload, jwtConfig.accessSecret, {
expiresIn: jwtConfig.accessExpiresIn as any,
});
const refreshToken = jwt.sign(payload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any,
});
const decoded = jwt.decode(refreshToken) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(user.id, refreshToken, expiresAt, metadata?.userAgent, metadata?.ipAddress);
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role.name,
},
};
}
async refresh(token: string, metadata?: { userAgent?: string; ipAddress?: string }): Promise<AuthTokensDto> {
let payload: any;
try {
payload = jwt.verify(token, jwtConfig.refreshSecret);
} catch (error) {
throw new AppError('Invalid refresh token', 401, ERROR_CODE.TOKEN_INVALID);
}
const savedToken = await this.repository.findRefreshToken(token);
if (!savedToken) {
throw new AppError('Invalid or expired refresh token', 401, ERROR_CODE.TOKEN_INVALID);
}
if (savedToken.expiresAt < new Date()) {
await this.repository.deleteRefreshToken(token);
throw new AppError('Refresh token expired', 401, ERROR_CODE.TOKEN_EXPIRED);
}
const user = await this.repository.findById(payload.id);
if (!user || !user.isActive) {
throw new AppError('User not found or inactive', 401, ERROR_CODE.USER_INACTIVE);
}
const newPayload = { id: user.id, email: user.email, role: user.role.name };
const newAccessToken = jwt.sign(newPayload, jwtConfig.accessSecret, {
expiresIn: jwtConfig.accessExpiresIn as any,
});
const newRefreshToken = jwt.sign(newPayload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any,
});
await this.repository.deleteRefreshToken(token);
const decoded = jwt.decode(newRefreshToken) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(user.id, newRefreshToken, expiresAt, metadata?.userAgent, metadata?.ipAddress);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
async logout(token: string) {
const result = await this.repository.deleteRefreshToken(token);
if (result.count === 0) {
throw new AppError('Refresh token not found or already invalidated', 400, ERROR_CODE.TOKEN_INVALID);
}
}
async getMe(userId: string): Promise<MeDto> {
const user = await this.repository.findById(userId);
if (!user || !user.isActive) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role.name,
isActive: user.isActive,
createdAt: user.createdAt,
};
}
}
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});
export const refreshSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});
export const logoutSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});
import { Request, Response, NextFunction } from 'express';
import { UserService } from './user.service';
import { UserQueryDto, CreateUserDto, UpdateUserDto } from './user.dto';
export class UserController {
private readonly service = new UserService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as UserQueryDto;
const result = await this.service.findAll(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findById(req.params.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as CreateUserDto;
const result = await this.service.create(body);
res.status(201).json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdateUserDto;
const result = await this.service.update(req.params.id, body);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
}
export interface UserQueryDto {
email?: string;
fullName?: string;
roleName?: string;
isActive?: boolean;
sortBy?: 'createdAt' | 'email' | 'fullName';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface CreateUserDto {
email: string;
password: string;
roleId: string;
}
export interface UpdateUserDto {
isActive?: boolean;
roleId?: string;
}
export interface UserResponseDto {
id: string;
email: string;
roleId: string;
role?: {
id: string;
name: string;
};
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { UserQueryDto } from './user.dto';
export class UserRepository {
async findAll(query: UserQueryDto) {
const {
email,
fullName,
roleName,
isActive,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = query;
const where: Prisma.UserWhereInput = {
...(email ? { email: { contains: email, mode: 'insensitive' } } : {}),
...(fullName ? { fullName: { contains: fullName, mode: 'insensitive' } } : {}),
...(isActive !== undefined ? { isActive } : {}),
...(roleName ? { role: { name: roleName } } : {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.user.findMany({
where,
include: { role: true },
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.user.count({ where }),
]);
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
findById(id: string) {
return prisma.user.findUnique({
where: { id },
include: { role: true },
});
}
findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
include: { role: true },
});
}
create(data: {
email: string;
passwordHash: string;
roleId: string;
}) {
return prisma.user.create({
data: {
email: data.email,
password: data.passwordHash,
roleId: data.roleId,
},
include: { role: true },
});
}
update(id: string, data: { isActive?: boolean; roleId?: string }) {
return prisma.user.update({
where: { id },
data,
include: { role: true },
});
}
}
import { Router } from 'express';
import { UserController } from './user.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { createUserSchema, findAllUserSchema, updateUserSchema } from './user.validation';
const router = Router();
const controller = new UserController();
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requireRole('ADMIN'), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN'), controller.findById);
router.post('/', authMiddleware, requireRole('ADMIN'), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requireRole('ADMIN'), validate(updateUserSchema), controller.update);
export default router;
import bcrypt from 'bcryptjs';
import { UserRepository } from './user.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { UserQueryDto, CreateUserDto, UpdateUserDto } from './user.dto';
export class UserService {
private readonly repository = new UserRepository();
async findAll(query: UserQueryDto) {
return this.repository.findAll(query);
}
async findById(id: string) {
const user = await this.repository.findById(id);
if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
return user;
}
async create(data: CreateUserDto) {
const existing = await this.repository.findByEmail(data.email);
if (existing) {
throw new AppError('Email already exists', 409, ERROR_CODE.DUPLICATE_ENTRY);
}
const passwordHash = await bcrypt.hash(data.password, 10);
return this.repository.create({
email: data.email,
passwordHash,
roleId: data.roleId,
});
}
async update(id: string, data: UpdateUserDto) {
await this.findById(id);
return this.repository.update(id, {
isActive: data.isActive,
roleId: data.roleId,
});
}
}
import { z } from 'zod';
export const findAllUserSchema = z.object({
email: z.string().optional(),
fullName: z.string().optional(),
roleName: z.enum(['ADMIN', 'LEADER', 'INTERN']).optional(),
isActive: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.optional(),
sortBy: z.enum(['createdAt', 'email', 'fullName']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createUserSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
roleId: z.string().uuid('Invalid roleId format'),
});
export const updateUserSchema = z.object({
isActive: z.boolean().optional(),
roleId: z.string().uuid('Invalid roleId format').optional(),
});
import { Router } from 'express';
import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route';
const router = Router();
router.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
router.use('/auth', authRoute);
router.use('/users', userRoute);
export default router;
import 'dotenv/config';
import app from './app';
import { envConfig } from './config/env.config';
const PORT = envConfig.port;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT} in ${envConfig.nodeEnv} mode`);
});
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
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