Commit 4af76950 authored by ThinhNC's avatar ThinhNC

feat(system): implement system optimization, security hardening, detailed...

feat(system): implement system optimization, security hardening, detailed health monitoring, and docker configurations
parent bc2abecc
......@@ -47,12 +47,16 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
HTTP input; `ai-assistant-response.validation.ts` chứa Zod schema kiểm tra output từ AI;
`ai-assistant-provider.schema.ts` chứa JSON Schema gửi cho provider. Service chỉ chọn và áp dụng
validator/schema theo use case, không khai báo Zod schema trực tiếp trong file service.
- Hệ thống tối ưu hóa và bảo mật sử dụng CacheService (Redis kết hợp in-memory fallback tự dọn dẹp) cho danh mục hệ thống và báo cáo tài chính; dữ liệu cache báo cáo tự động xóa theo pattern khi ví, giao dịch, ngân sách hoặc mục tiêu tiết kiệm thay đổi.
- LockService cung cấp phân phối khoá (Redis hoặc memory fallback) nhằm ngăn chặn tranh chấp chạy song song của worker nền trong môi trường production.
- Rate Limiting tổng thể được xây dựng để sử dụng Redis (kết hợp memory fallback an toàn, có cơ chế tự giải phóng dữ liệu tránh rò rỉ bộ nhớ).
- Hệ thống log sử dụng LoggerService, đầu ra JSON ở production và text màu ở development, hỗ trợ ẩn thông tin nhạy cảm.
- Môi trường production được container hóa bằng Dockerfile (multi-stage) chạy với user phi quản trị và docker-compose.yml có thiết lập kiểm tra sức khoẻ (healthcheck) cho Postgres và Redis.
## Trạng thái đã biết
- Chưa có test script hoặc test suite trong `package.json`.
- `lint` script tồn tại nhưng repository hiện chưa có ESLint config; không coi
lint là verification khả dụng cho tới khi config được bổ sung.
- Hệ thống linting đã được cấu hình qua `eslint.config.mjs` (flat config) và chạy sạch sẽ khi gọi `pnpm run lint`.
- `env.config.ts` dùng port fallback `8888`, còn `.env.example` dùng `7777`; README
ghi rõ cả hai và dùng `7777` cho hướng dẫn chạy theo file env mẫu.
- Wallet, Category, Transaction và Budget đã có API theo ownership; Category đồng
......
......@@ -13,8 +13,13 @@ JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=redis
REDIS_PORT=6381
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_ENABLED=true
RATE_LIMIT_MAX_REQUESTS=1000
RATE_LIMIT_WINDOW_MS=900000
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
......
name: FinWise CI Pipeline
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
build-and-test:
name: Build, Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9.15.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Validate Prisma Schema
run: pnpm exec prisma validate
- name: Run Lint Rules
run: pnpm run lint
- name: Build Code Compilation
run: pnpm run build
# --- BUILD STAGE ---
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
# Enable corepack to use pnpm defined in package.json
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy package descriptors first to leverage Docker layer caching
COPY package.json pnpm-lock.yaml ./
COPY prisma/schema.prisma ./prisma/
# Install all dependencies (including devDependencies)
RUN pnpm install --frozen-lockfile
# Generate Prisma Client
RUN pnpm run prisma:generate
# Copy source code and config
COPY tsconfig.json ./
COPY src/ ./src/
# Compile TypeScript code to JavaScript (outputs to dist/)
RUN pnpm run build
# --- RUNTIME STAGE ---
FROM node:20-alpine AS runner
WORKDIR /usr/src/app
# Enable corepack to use pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Set runtime environment
ENV NODE_ENV=production
ENV PORT=8888
# Create storage directory for local receipts
RUN mkdir -p storage/receipts && chown -R node:node storage
# Copy package descriptors
COPY package.json pnpm-lock.yaml ./
COPY prisma/ ./prisma/
# Install only production dependencies
RUN pnpm install --prod --frozen-lockfile
# Copy compiled files from builder stage
COPY --from=builder /usr/src/app/dist ./dist
# Copy generated Prisma Client from builder stage
COPY --from=builder /usr/src/app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /usr/src/app/node_modules/@prisma/client ./node_modules/@prisma/client
# Use non-root node user for security hardening
USER node
# Expose port
EXPOSE 8888
# Execute migrations deploy and start application
CMD ["pnpm", "start"]
......@@ -12,44 +12,50 @@ services:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-datafinwise}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: finwise_redis
ports:
- "${REDIS_PORT:-6381}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
# 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"
app:
build:
context: .
dockerfile: Dockerfile
container_name: finwise_app
restart: always
ports:
- "${PORT:-8888}:${PORT:-8888}"
env_file:
- .env
environment:
NODE_ENV: "${NODE_ENV:-production}"
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"
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_ENABLED: "true"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
postgres_data:
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'dist/**/*',
'node_modules/**/*',
'scripts/**/*',
'prisma/**/*',
'eslint.config.mjs',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-namespace': 'off',
'no-console': 'off',
'no-undef': 'off', // TypeScript compiler already checks undefined variables
},
}
);
......@@ -27,6 +27,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"ioredis": "^6.0.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
......@@ -35,6 +36,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
......@@ -51,7 +53,8 @@
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"typescript-eslint": "^8.66.0"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
......
......@@ -29,6 +29,9 @@ importers:
helmet:
specifier: ^8.0.0
version: 8.2.0
ioredis:
specifier: ^6.0.0
version: 6.0.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.3
......@@ -48,6 +51,9 @@ importers:
specifier: ^3.24.1
version: 3.25.76
devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@9.39.4)
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
......@@ -99,6 +105,9 @@ importers:
typescript:
specifier: ^5.7.2
version: 5.9.3
typescript-eslint:
specifier: ^8.66.0
version: 8.66.0(eslint@9.39.4)(typescript@5.9.3)
packages:
......@@ -288,6 +297,15 @@ packages:
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@10.0.1':
resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
peerDependencies:
eslint: ^10.0.0
peerDependenciesMeta:
eslint:
optional: true
'@eslint/js@9.39.4':
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
......@@ -320,6 +338,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@ioredis/commands@2.0.0':
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
......@@ -446,6 +467,65 @@ packages:
'@types/swagger-ui-express@4.1.8':
resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
'@typescript-eslint/eslint-plugin@8.66.0':
resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.66.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.66.0':
resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.66.0':
resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.66.0':
resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.66.0':
resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.66.0':
resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.66.0':
resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.66.0':
resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.66.0':
resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.66.0':
resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
......@@ -490,6 +570,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
basic-auth@2.0.1:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
......@@ -508,6 +592,10 @@ packages:
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
......@@ -546,6 +634,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
cluster-key-slot@1.1.1:
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
engines: {node: '>=0.10.0'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
......@@ -613,6 +705,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
......@@ -682,6 +778,10 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@9.39.4:
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
......@@ -729,6 +829,15 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
......@@ -827,6 +936,10 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.6:
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
engines: {node: '>= 4'}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
......@@ -842,6 +955,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ioredis@6.0.0:
resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==}
engines: {node: '>=20.0.0'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
......@@ -958,6 +1075,10 @@ packages:
engines: {node: '>=4'}
hasBin: true
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
......@@ -1059,6 +1180,10 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
......@@ -1101,6 +1226,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
......@@ -1171,6 +1300,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
......@@ -1211,6 +1343,10 @@ packages:
peerDependencies:
express: '>=4.0.0 || >=5.0.0-beta'
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
......@@ -1223,6 +1359,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-node-dev@2.0.0:
resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==}
engines: {node: '>=0.8.0'}
......@@ -1267,6 +1409,13 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript-eslint@8.66.0:
resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
......@@ -1444,6 +1593,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@eslint/js@10.0.1(eslint@9.39.4)':
optionalDependencies:
eslint: 9.39.4
'@eslint/js@9.39.4': {}
'@eslint/object-schema@2.1.7': {}
......@@ -1469,6 +1622,8 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@ioredis/commands@2.0.0': {}
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
......@@ -1605,6 +1760,97 @@ snapshots:
'@types/express': 4.17.25
'@types/serve-static': 1.15.10
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.66.0(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/type-utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.66.0
eslint: 9.39.4
ignore: 7.0.6
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.66.0
debug: 4.4.3
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.66.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3)
'@typescript-eslint/types': 8.66.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.66.0':
dependencies:
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/visitor-keys': 8.66.0
'@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.66.0(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.4
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.66.0': {}
'@typescript-eslint/typescript-estree@8.66.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.66.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3)
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/visitor-keys': 8.66.0
debug: 4.4.3
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.66.0(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
'@typescript-eslint/scope-manager': 8.66.0
'@typescript-eslint/types': 8.66.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.66.0':
dependencies:
'@typescript-eslint/types': 8.66.0
eslint-visitor-keys: 5.0.1
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
......@@ -1646,6 +1892,8 @@ snapshots:
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
basic-auth@2.0.1:
dependencies:
safe-buffer: 5.1.2
......@@ -1676,6 +1924,10 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
fill-range: 7.1.1
......@@ -1719,6 +1971,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
cluster-key-slot@1.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
......@@ -1774,6 +2028,8 @@ snapshots:
deep-is@0.1.4: {}
denque@2.1.0: {}
depd@2.0.0: {}
destroy@1.2.0: {}
......@@ -1850,6 +2106,8 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
eslint-visitor-keys@5.0.1: {}
eslint@9.39.4:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
......@@ -1951,6 +2209,10 @@ snapshots:
fast-levenshtein@2.0.6: {}
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
......@@ -2057,6 +2319,8 @@ snapshots:
ignore@5.3.2: {}
ignore@7.0.6: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
......@@ -2071,6 +2335,17 @@ snapshots:
inherits@2.0.4: {}
ioredis@6.0.0:
dependencies:
'@ioredis/commands': 2.0.0
cluster-key-slot: 1.1.1
debug: 4.4.3
denque: 2.1.0
redis-errors: 1.2.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@1.9.1: {}
is-binary-path@2.1.0:
......@@ -2172,6 +2447,10 @@ snapshots:
mime@1.6.0: {}
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.9
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.15
......@@ -2258,6 +2537,8 @@ snapshots:
picomatch@2.3.2: {}
picomatch@4.0.5: {}
prelude-ls@1.2.1: {}
prettier@3.9.4: {}
......@@ -2299,6 +2580,8 @@ snapshots:
dependencies:
picomatch: 2.3.2
redis-errors@1.2.0: {}
resolve-from@4.0.0: {}
resolve@1.22.12:
......@@ -2390,6 +2673,8 @@ snapshots:
source-map@0.6.1: {}
standard-as-callback@2.1.0: {}
statuses@2.0.2: {}
streamsearch@1.1.0: {}
......@@ -2419,6 +2704,11 @@ snapshots:
express: 4.22.2
swagger-ui-dist: 5.32.8
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
......@@ -2427,6 +2717,10 @@ snapshots:
tree-kill@1.2.2: {}
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-node-dev@2.0.0(@types/node@22.20.0)(typescript@5.9.3):
dependencies:
chokidar: 3.6.0
......@@ -2487,6 +2781,17 @@ snapshots:
typedarray@0.0.6: {}
typescript-eslint@8.66.0(eslint@9.39.4)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.66.0(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3)
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
undici-types@6.21.0: {}
......
import Redis from 'ioredis';
import { envConfig } from '../../config/env.config';
import { LoggerService } from './logger.service';
interface CacheEntry {
value: any;
expiresAt: number | null;
}
export class CacheService {
private readonly logger = new LoggerService('CacheService');
private redis: Redis | null = null;
private isRedisConnected = false;
// In-Memory Fallback Cache
private readonly memoryCache = new Map<string, CacheEntry>();
private readonly maxMemoryKeys = 1000;
private memoryCleanupInterval: NodeJS.Timeout | null = null;
constructor() {
if (envConfig.redis.enabled) {
this.initRedis();
} else {
this.logger.info('Redis is disabled, using in-memory cache fallback.');
this.initMemoryCleanup();
}
}
private initRedis() {
try {
this.redis = new Redis({
host: envConfig.redis.host,
port: envConfig.redis.port,
password: envConfig.redis.password,
lazyConnect: true,
maxRetriesPerRequest: 3,
retryStrategy: (times) => {
if (times > 3) {
this.logger.warn('Failed to connect to Redis. Falling back to in-memory cache.');
this.isRedisConnected = false;
this.redis?.disconnect();
this.initMemoryCleanup();
return null; // Stop retrying
}
return Math.min(times * 100, 2000);
},
});
this.redis.on('connect', () => {
this.logger.info('Successfully connected to Redis.');
this.isRedisConnected = true;
this.stopMemoryCleanup();
});
this.redis.on('error', (err) => {
this.logger.error('Redis error occurred:', err);
this.isRedisConnected = false;
this.initMemoryCleanup();
});
this.redis.on('close', () => {
this.logger.warn('Redis connection closed.');
this.isRedisConnected = false;
this.initMemoryCleanup();
});
// Async connect in background
this.redis.connect().catch((err) => {
this.logger.error('Error during initial Redis connection:', err);
this.isRedisConnected = false;
this.initMemoryCleanup();
});
} catch (error) {
this.logger.error('Failed to initialize Redis client. Falling back to in-memory.', error);
this.isRedisConnected = false;
this.initMemoryCleanup();
}
}
private initMemoryCleanup() {
if (this.memoryCleanupInterval) return;
// Prune expired entries every 5 minutes
this.memoryCleanupInterval = setInterval(() => {
this.pruneMemoryCache();
}, 5 * 60 * 1000);
// Allow the process to exit if only this timer is running
this.memoryCleanupInterval.unref();
}
private stopMemoryCleanup() {
if (this.memoryCleanupInterval) {
clearInterval(this.memoryCleanupInterval);
this.memoryCleanupInterval = null;
}
}
private pruneMemoryCache() {
const now = Date.now();
let prunedCount = 0;
for (const [key, entry] of this.memoryCache.entries()) {
if (entry.expiresAt !== null && now > entry.expiresAt) {
this.memoryCache.delete(key);
prunedCount++;
}
}
if (prunedCount > 0) {
this.logger.debug(`Pruned ${prunedCount} expired entries from in-memory cache.`);
}
}
async get<T>(key: string): Promise<T | null> {
if (this.isRedisConnected && this.redis) {
try {
const data = await this.redis.get(key);
if (!data) return null;
return JSON.parse(data) as T;
} catch (error) {
this.logger.error(`Error getting key "${key}" from Redis:`, error);
// Fallback to memory read in case Redis query fails
}
}
// In-memory read
const entry = this.memoryCache.get(key);
if (!entry) return null;
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
this.memoryCache.delete(key);
return null;
}
return entry.value as T;
}
async set(key: string, value: any, ttlSeconds?: number): Promise<void> {
if (this.isRedisConnected && this.redis) {
try {
const serialized = JSON.stringify(value);
if (ttlSeconds && ttlSeconds > 0) {
await this.redis.set(key, serialized, 'EX', ttlSeconds);
} else {
await this.redis.set(key, serialized);
}
return;
} catch (error) {
this.logger.error(`Error setting key "${key}" in Redis:`, error);
// Fallback to memory set
}
}
// In-memory write
if (this.memoryCache.size >= this.maxMemoryKeys) {
// Evict first key (FIFO approximation since JS Map maintains insertion order)
const firstKey = this.memoryCache.keys().next().value;
if (firstKey !== undefined) {
this.memoryCache.delete(firstKey);
}
}
const expiresAt = ttlSeconds && ttlSeconds > 0
? Date.now() + ttlSeconds * 1000
: null;
this.memoryCache.set(key, { value, expiresAt });
}
async del(key: string): Promise<void> {
if (this.isRedisConnected && this.redis) {
try {
await this.redis.del(key);
return;
} catch (error) {
this.logger.error(`Error deleting key "${key}" in Redis:`, error);
}
}
this.memoryCache.delete(key);
}
/**
* Clears all keys matching a pattern (e.g. "finwise:cache:reports:userId:*")
*/
async clearPattern(pattern: string): Promise<void> {
this.logger.debug(`Clearing cache pattern: ${pattern}`);
if (this.isRedisConnected && this.redis) {
try {
// Convert glob pattern if needed (Redis keys matching uses glob syntax out of the box)
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH',
pattern,
'COUNT',
100
);
cursor = nextCursor;
if (keys.length > 0) {
await this.redis.del(...keys);
}
} while (cursor !== '0');
return;
} catch (error) {
this.logger.error(`Error scanning/deleting keys matching "${pattern}" in Redis:`, error);
}
}
// In-memory pattern clear
// Convert glob pattern to RegExp: escape special characters, replace * with .*
const regexPattern = new RegExp(
'^' + pattern.replace(/[-/\\^$+.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*') + '$'
);
for (const key of this.memoryCache.keys()) {
if (regexPattern.test(key)) {
this.memoryCache.delete(key);
}
}
}
// Get raw client connection (for health check / monitoring)
getRedisClient() {
return this.redis;
}
isUsingRedis(): boolean {
return this.isRedisConnected;
}
}
export const cacheService = new CacheService();
import { cacheService } from './cache.service';
import { LoggerService } from './logger.service';
interface LocalLock {
expiresAt: number;
}
export class LockService {
private readonly logger = new LoggerService('LockService');
private readonly localLocks = new Map<string, LocalLock>();
/**
* Acquires a lock.
* @param lockKey Key of the lock (e.g. "finwise:lock:notification-worker")
* @param ttlMs Time-to-live for the lock in milliseconds
* @returns true if lock was acquired successfully, false otherwise
*/
async acquire(lockKey: string, ttlMs: number): Promise<boolean> {
const isUsingRedis = cacheService.isUsingRedis();
const redisClient = cacheService.getRedisClient();
if (isUsingRedis && redisClient) {
try {
const result = await redisClient.set(lockKey, 'locked', 'PX', ttlMs, 'NX');
return result === 'OK';
} catch (error) {
this.logger.error(`Redis error acquiring lock for key "${lockKey}":`, error);
// Fallback to local lock simulation
}
}
// Fallback: Local In-Memory Lock simulation
const now = Date.now();
const existing = this.localLocks.get(lockKey);
if (existing && now < existing.expiresAt) {
// Lock is still active/held
return false;
}
// Set lock
this.localLocks.set(lockKey, { expiresAt: now + ttlMs });
return true;
}
/**
* Releases a lock.
* @param lockKey Key of the lock
*/
async release(lockKey: string): Promise<void> {
const isUsingRedis = cacheService.isUsingRedis();
const redisClient = cacheService.getRedisClient();
if (isUsingRedis && redisClient) {
try {
await redisClient.del(lockKey);
return;
} catch (error) {
this.logger.error(`Redis error releasing lock for key "${lockKey}":`, error);
}
}
this.localLocks.delete(lockKey);
}
}
export const lockService = new LockService();
import { envConfig } from '../../config/env.config';
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
const SENSITIVE_KEYS = new Set([
'password',
'token',
'accesstoken',
'refreshtoken',
'apikey',
'apikeys',
'secret',
'authorization',
'cookie',
'transport',
'geminiapikeys',
]);
function redact(obj: any): any {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(redact);
}
const redacted: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
const lowerKey = key.toLowerCase();
if (SENSITIVE_KEYS.has(lowerKey)) {
redacted[key] = '[REDACTED]';
} else if (typeof value === 'object') {
redacted[key] = redact(value);
} else {
redacted[key] = value;
}
}
return redacted;
}
export class LoggerService {
private readonly context: string;
constructor(context = 'App') {
this.context = context;
}
info(message: string, ...args: any[]): void {
this.log('info', message, args);
}
warn(message: string, ...args: any[]): void {
this.log('warn', message, args);
}
error(message: string, error?: any, ...args: any[]): void {
const errorDetails = error instanceof Error
? { ...error, message: error.message, stack: error.stack }
: error;
this.log('error', message, [errorDetails, ...args]);
}
debug(message: string, ...args: any[]): void {
if (envConfig.nodeEnv === 'development') {
this.log('debug', message, args);
}
}
private log(level: LogLevel, message: string, args: any[]): void {
const timestamp = new Date().toISOString();
const cleanArgs = args.map(redact);
if (envConfig.nodeEnv === 'production') {
const logPayload = {
timestamp,
level: level.toUpperCase(),
context: this.context,
message,
...(cleanArgs.length > 0 ? { details: cleanArgs } : {}),
};
console.log(JSON.stringify(logPayload));
} else {
const color = this.getColor(level);
const reset = '\x1b[0m';
const formattedDetails = cleanArgs.length > 0
? '\n' + JSON.stringify(cleanArgs, null, 2)
: '';
console.log(
`[${timestamp}] ${color}${level.toUpperCase()}${reset} [${this.context}]: ${message}${formattedDetails}`
);
}
}
private getColor(level: LogLevel): string {
switch (level) {
case 'info':
return '\x1b[32m'; // green
case 'warn':
return '\x1b[33m'; // yellow
case 'error':
return '\x1b[31m'; // red
case 'debug':
return '\x1b[36m'; // cyan
default:
return '';
}
}
}
export const logger = new LoggerService();
......@@ -95,4 +95,14 @@ export const envConfig = {
})(),
},
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
enabled: process.env.REDIS_ENABLED !== 'false',
},
rateLimit: {
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000', 10),
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes default
},
};
......@@ -1818,23 +1818,67 @@ export const swaggerSpec = {
get: {
tags: ['System'],
summary: 'Health check',
description: 'Kiểm tra trạng thái hoạt động của Server, Database PostgreSQL và Cache Redis.',
responses: {
200: {
description: 'Server đang chạy',
description: 'Hệ thống hoạt động bình thường',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
status: { type: 'string', example: 'ok' },
timestamp: { type: 'string', format: 'date-time' },
},
},
},
},
timestamp: { type: 'string', format: 'date-time', example: '2026-08-06T10:15:21Z' },
uptime: { type: 'number', example: 120.45 },
memory: {
type: 'object',
properties: {
rss: { type: 'string', example: '85.50 MB' },
heapTotal: { type: 'string', example: '45.20 MB' },
heapUsed: { type: 'string', example: '22.10 MB' }
}
},
database: {
type: 'object',
properties: {
status: { type: 'string', example: 'up' },
latencyMs: { type: 'number', example: 5 }
}
},
cache: {
type: 'object',
properties: {
status: { type: 'string', example: 'up' },
type: { type: 'string', example: 'redis' }
}
}
}
}
}
}
},
503: {
description: 'Có lỗi kết nối cơ sở dữ liệu hoặc hệ thống dịch vụ',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
status: { type: 'string', example: 'error' },
timestamp: { type: 'string', format: 'date-time' },
uptime: { type: 'number' },
memory: { type: 'object' },
database: { type: 'object' },
cache: { type: 'object' }
}
}
}
}
}
}
}
},
'/auth/register': {
post: {
......
import { Request, Response, NextFunction } from 'express';
import { envConfig } from '../config/env.config';
import { cacheService } from '../common/services/cache.service';
const requestCounts = new Map<string, { count: number; resetAt: number }>();
interface RateLimitRecord {
count: number;
resetAt: number;
}
const WINDOW_MS = 15 * 60 * 1000;
const MAX_REQUESTS = 1000;
const requestCounts = new Map<string, RateLimitRecord>();
let lastCleanupAt = 0;
export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
export async function rateLimitMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const maxRequests = envConfig.rateLimit.maxRequests;
const windowMs = envConfig.rateLimit.windowMs;
const isUsingRedis = cacheService.isUsingRedis();
const redisClient = cacheService.getRedisClient();
if (isUsingRedis && redisClient) {
try {
const redisKey = `finwise:rate-limit:${ip}`;
const currentCount = await redisClient.incr(redisKey);
if (currentCount === 1) {
await redisClient.pexpire(redisKey, windowMs);
}
if (currentCount > maxRequests) {
const ttlMs = await redisClient.pttl(redisKey);
const retryAfterSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
message: 'Too many requests, please try again later',
code: 'RATE_LIMIT_EXCEEDED',
});
return;
}
next();
return;
} catch (error) {
// In case of Redis failure, fall back to memory rate limiting silently
console.error('Redis rate limiting failed. Falling back to memory rate limiting.', error);
}
}
// Memory-safe rate limit fallback
if (now - lastCleanupAt >= windowMs) {
requestCounts.forEach((record, key) => {
if (now >= record.resetAt) {
requestCounts.delete(key);
}
});
lastCleanupAt = now;
}
const record = requestCounts.get(ip);
if (!record || now > record.resetAt) {
requestCounts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
if (!record || now >= record.resetAt) {
requestCounts.set(ip, {
count: 1,
resetAt: now + windowMs,
});
next();
return;
}
record.count += 1;
if (record.count > MAX_REQUESTS) {
if (record.count > maxRequests) {
const retryAfterSeconds = Math.max(1, Math.ceil((record.resetAt - now) / 1000));
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
message: 'Too many requests, please try again later',
......@@ -30,3 +88,4 @@ export function rateLimitMiddleware(req: Request, res: Response, next: NextFunct
next();
}
......@@ -6,6 +6,7 @@ import {
} from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { cacheService } from '../../common/services/cache.service';
import {
BudgetQueryDto,
BudgetResponseDto,
......@@ -44,6 +45,7 @@ export class BudgetService {
async create(userId: string, data: CreateBudgetDto) {
const persistence = await this.resolveCreateData(userId, data);
const budget = await this.repository.create(userId, persistence);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget);
}
......@@ -60,6 +62,7 @@ export class BudgetService {
const persistence = await this.resolveUpdateData(userId, current, data);
const budget = await this.repository.update(id, persistence);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget);
}
......@@ -69,6 +72,7 @@ export class BudgetService {
? current
: await this.repository.archive(id);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget);
}
......@@ -84,6 +88,7 @@ export class BudgetService {
}
const budget = await this.repository.restore(id);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget);
}
......@@ -348,4 +353,8 @@ export class BudgetService {
return 'ACTIVE';
}
private async invalidateReportCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:reports:${userId}:*`);
}
}
......@@ -10,15 +10,42 @@ import {
CategoryQueryDto,
} from './category.dto';
import { CategoryRepository } from './category.repository';
import { cacheService } from '../../common/services/cache.service';
export class CategoryService {
private readonly repository = new CategoryRepository();
findAll(userId: string, query: CategoryQueryDto) {
return this.repository.findAll(userId, query);
async findAll(userId: string, query: CategoryQueryDto) {
const isSystemOnly = query.source === 'SYSTEM';
const cacheKey = `finwise:cache:categories:system:list:${JSON.stringify(query)}`;
if (isSystemOnly) {
const cached = await cacheService.get<any>(cacheKey);
if (cached) {
return cached;
}
}
const result = await this.repository.findAll(userId, query);
if (isSystemOnly) {
await cacheService.set(cacheKey, result, 3600); // Cache for 1 hour
}
return result;
}
async findTree(userId: string, query: CategoryTreeQueryDto) {
const isSystemOnly = query.source === 'SYSTEM';
const cacheKey = `finwise:cache:categories:system:tree:${JSON.stringify(query)}`;
if (isSystemOnly) {
const cached = await cacheService.get<any>(cacheKey);
if (cached) {
return cached;
}
}
const categories = await this.repository.findAllForTree(userId, query);
const nodes = new Map<string, CategoryTreeNodeDto>();
......@@ -41,10 +68,8 @@ export class CategoryService {
}
}
if (!query.search) {
return roots;
}
let finalRoots = roots;
if (query.search) {
const search = query.search.toLocaleLowerCase();
const prune = (node: CategoryTreeNodeDto): CategoryTreeNodeDto | null => {
const children = node.children
......@@ -58,11 +83,18 @@ export class CategoryService {
return null;
};
return roots
finalRoots = roots
.map(prune)
.filter((node): node is CategoryTreeNodeDto => node !== null);
}
if (isSystemOnly) {
await cacheService.set(cacheKey, finalRoots, 3600); // Cache for 1 hour
}
return finalRoots;
}
async findById(userId: string, id: string) {
const category = await this.repository.findById(userId, id);
......
......@@ -3,6 +3,8 @@ import { ReminderService } from '../reminders/reminder.service';
import { NotificationDeliveryService } from './notification-delivery.service';
import { NotificationService } from './notification.service';
import { lockService } from '../../common/services/lock.service';
export class NotificationWorker {
private readonly reminderService = new ReminderService();
private readonly notificationService = new NotificationService();
......@@ -34,9 +36,19 @@ export class NotificationWorker {
if (this.running) {
return;
}
const lockKey = 'finwise:lock:notification-worker';
const lockTtlMs = 5 * 60 * 1000; // 5 minutes max lock duration
const lockAcquired = await lockService.acquire(lockKey, lockTtlMs);
if (!lockAcquired) {
return;
}
this.running = true;
const now = new Date();
try {
try {
await this.reminderService.processDue(now);
} catch (error) {
......@@ -60,8 +72,10 @@ export class NotificationWorker {
console.error('Notification worker failed to scan financial alerts', error);
}
}
} finally {
this.running = false;
await lockService.release(lockKey);
}
}
}
......
......@@ -30,6 +30,8 @@ import {
ReportWalletRecord,
} from './report.repository';
import { cacheService } from '../../common/services/cache.service';
const MILLISECONDS_PER_MINUTE = 60 * 1000;
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const MAX_CUSTOM_RANGE_DAYS = 5 * 366;
......@@ -51,6 +53,12 @@ export class ReportService {
private readonly repository = new ReportRepository();
async getOverview(userId: string, query: ReportQueryDto): Promise<FinancialOverviewDto> {
const cacheKey = `finwise:cache:reports:${userId}:overview:${JSON.stringify(query)}`;
const cached = await cacheService.get<FinancialOverviewDto>(cacheKey);
if (cached) {
return cached;
}
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const [transactions, budgets, goals] = await Promise.all([
......@@ -76,7 +84,7 @@ export class ReportService {
const flows = this.toMoneyFlows(transactions, knownCurrencies);
const currentBalances = this.sumWalletBalances(scope.wallets);
return {
const result = {
period,
metricsByCurrency: flows.map((flow) => {
const income = new Prisma.Decimal(flow.income);
......@@ -109,9 +117,18 @@ export class ReportService {
periodContributions,
),
};
await cacheService.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
}
async getCashFlow(userId: string, query: ReportQueryDto): Promise<CashFlowReportDto> {
const cacheKey = `finwise:cache:reports:${userId}:cashflow:${JSON.stringify(query)}`;
const cached = await cacheService.get<CashFlowReportDto>(cacheKey);
if (cached) {
return cached;
}
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const transactions = await this.repository.findTransactions(
......@@ -137,7 +154,7 @@ export class ReportService {
transactionsByBucket.set(key, bucket);
});
return {
const result = {
period,
granularity,
totalsByCurrency: this.toMoneyFlows(transactions, knownCurrencies),
......@@ -150,12 +167,21 @@ export class ReportService {
),
})),
};
await cacheService.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
}
async getSpendingByCategory(
userId: string,
query: ReportQueryDto,
): Promise<SpendingCategoryReportDto> {
const cacheKey = `finwise:cache:reports:${userId}:spending-category:${JSON.stringify(query)}`;
const cached = await cacheService.get<SpendingCategoryReportDto>(cacheKey);
if (cached) {
return cached;
}
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const transactions = await this.repository.findTransactions(
......@@ -170,7 +196,7 @@ export class ReportService {
);
const currencies = this.getKnownCurrencies(scope.wallets, expenses, scope.currency);
return {
const result = {
period,
currencies: currencies.map((currency) => {
const currencyExpenses = expenses.filter(
......@@ -213,12 +239,21 @@ export class ReportService {
};
}),
};
await cacheService.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
}
async getBudgetPerformance(
userId: string,
query: ReportQueryDto,
): Promise<BudgetPerformanceReportDto> {
const cacheKey = `finwise:cache:reports:${userId}:budget-performance:${JSON.stringify(query)}`;
const cached = await cacheService.get<BudgetPerformanceReportDto>(cacheKey);
if (cached) {
return cached;
}
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const [transactions, budgets] = await Promise.all([
......@@ -232,11 +267,14 @@ export class ReportService {
this.repository.findBudgets(userId, period.from, period.to, scope.currency),
]);
return {
const result = {
period,
summary: this.buildBudgetSummary(budgets, transactions, period),
budgets: this.buildBudgetItems(budgets, transactions, period),
};
await cacheService.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
}
private async resolveScope(userId: string, query: ReportQueryDto): Promise<ResolvedScope> {
......
import { Prisma, SavingGoalStatus } from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { cacheService } from '../../common/services/cache.service';
import {
CreateSavingContributionDto,
CreateSavingGoalDto,
......@@ -49,11 +50,12 @@ export class SavingGoalService {
async create(userId: string, data: CreateSavingGoalDto) {
const goal = await this.repository.create(userId, data);
await this.invalidateReportCache(userId);
return this.toResponse(goal, this.emptySummary(goal.id));
}
update(userId: string, id: string, data: UpdateSavingGoalDto) {
return this.repository.runSerializable(async (transaction) => {
async update(userId: string, id: string, data: UpdateSavingGoalDto) {
const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction);
this.ensureMutable(current);
const summary = await this.repository.findSummary(id, transaction);
......@@ -88,10 +90,13 @@ export class SavingGoalService {
return this.toResponse(goal, summary);
});
await this.invalidateReportCache(userId);
return result;
}
archive(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => {
async archive(userId: string, id: string) {
const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction);
const goal = current.isArchived
? current
......@@ -100,10 +105,13 @@ export class SavingGoalService {
return this.toResponse(goal, summary);
});
await this.invalidateReportCache(userId);
return result;
}
restore(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => {
async restore(userId: string, id: string) {
const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction);
const summary = await this.repository.findSummary(id, transaction);
......@@ -125,6 +133,9 @@ export class SavingGoalService {
return this.toResponse(goal, summary);
});
await this.invalidateReportCache(userId);
return result;
}
async findContributions(
......@@ -136,12 +147,12 @@ export class SavingGoalService {
return this.repository.findContributions(savingGoalId, query);
}
createContribution(
async createContribution(
userId: string,
savingGoalId: string,
data: CreateSavingContributionDto,
) {
return this.repository.runSerializable(async (transaction) => {
const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureCanContribute(current);
const contribution = await this.repository.createContribution(
......@@ -159,15 +170,18 @@ export class SavingGoalService {
goal: this.toResponse(goal, summary),
};
});
await this.invalidateReportCache(userId);
return result;
}
updateContribution(
async updateContribution(
userId: string,
savingGoalId: string,
contributionId: string,
data: UpdateSavingContributionDto,
) {
return this.repository.runSerializable(async (transaction) => {
const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureMutable(current);
await this.findContributionRecord(
......@@ -190,14 +204,17 @@ export class SavingGoalService {
goal: this.toResponse(goal, summary),
};
});
await this.invalidateReportCache(userId);
return result;
}
deleteContribution(
async deleteContribution(
userId: string,
savingGoalId: string,
contributionId: string,
) {
return this.repository.runSerializable(async (transaction) => {
const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureMutable(current);
await this.findContributionRecord(
......@@ -219,6 +236,9 @@ export class SavingGoalService {
goal: this.toResponse(goal, summary),
};
});
await this.invalidateReportCache(userId);
return result;
}
private async findRecord(
......@@ -371,4 +391,7 @@ export class SavingGoalService {
lastContributionAt: null,
};
}
private async invalidateReportCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:reports:${userId}:*`);
}
}
......@@ -3,6 +3,7 @@ import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { NotificationService } from '../notifications/notification.service';
import { ReceiptFileService, StoredReceipt } from './receipt-file.service';
import { cacheService } from '../../common/services/cache.service';
import {
CreateTransactionDto,
TransactionQueryDto,
......@@ -55,6 +56,7 @@ export class TransactionService {
});
await this.notificationService.detectUnusualTransaction(userId, created);
await this.invalidateReportCache(userId);
return created;
}
......@@ -110,6 +112,7 @@ export class TransactionService {
});
await this.notificationService.detectUnusualTransaction(userId, updated);
await this.invalidateReportCache(userId);
return updated;
}
......@@ -141,6 +144,7 @@ export class TransactionService {
});
await this.receiptFiles.remove(deleted.receiptUrl);
await this.invalidateReportCache(userId);
return { id: deleted.id };
}
......@@ -159,6 +163,7 @@ export class TransactionService {
}
await this.receiptFiles.remove(result.previousReceiptKey);
await this.invalidateReportCache(userId);
return result.transaction;
} catch (error) {
await this.receiptFiles.remove(receiptKey);
......@@ -191,6 +196,7 @@ export class TransactionService {
}
await this.receiptFiles.remove(result.previousReceiptKey);
await this.invalidateReportCache(userId);
return result.transaction;
}
......@@ -238,4 +244,8 @@ export class TransactionService {
);
}
}
private async invalidateReportCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:reports:${userId}:*`);
}
}
......@@ -3,6 +3,7 @@ import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { CreateWalletDto, UpdateWalletDto, WalletQueryDto } from './wallet.dto';
import { WalletRepository } from './wallet.repository';
import { cacheService } from '../../common/services/cache.service';
export class WalletService {
private readonly repository = new WalletRepository();
......@@ -25,7 +26,9 @@ export class WalletService {
await this.ensureUniqueName(userId, data.name);
try {
return await this.repository.create(userId, data);
const wallet = await this.repository.create(userId, data);
await this.invalidateReportCache(userId);
return wallet;
} catch (error) {
this.handleUniqueConstraint(error);
throw error;
......@@ -40,7 +43,9 @@ export class WalletService {
}
try {
return await this.repository.update(id, data);
const wallet = await this.repository.update(id, data);
await this.invalidateReportCache(userId);
return wallet;
} catch (error) {
this.handleUniqueConstraint(error);
throw error;
......@@ -64,6 +69,7 @@ export class WalletService {
throw new AppError('Archived wallet cannot be set as default', 409, ERROR_CODE.WALLET_ARCHIVED);
}
await this.invalidateReportCache(userId);
return updatedWallet;
}
......@@ -92,6 +98,7 @@ export class WalletService {
);
}
await this.invalidateReportCache(userId);
return archivedWallet;
}
......@@ -102,7 +109,9 @@ export class WalletService {
return wallet;
}
return this.repository.restore(userId, id);
const restoredWallet = await this.repository.restore(userId, id);
await this.invalidateReportCache(userId);
return restoredWallet;
}
private async ensureUniqueName(userId: string, name: string, excludeId?: string) {
......@@ -118,4 +127,8 @@ export class WalletService {
throw new AppError('Wallet name already exists', 409, ERROR_CODE.DUPLICATE_ENTRY);
}
}
private async invalidateReportCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:reports:${userId}:*`);
}
}
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../database/prisma.client';
import { cacheService } from '../common/services/cache.service';
export async function healthCheck(req: Request, res: Response, next: NextFunction): Promise<void> {
const timestamp = new Date().toISOString();
const uptime = process.uptime();
const memoryUsage = process.memoryUsage();
let dbStatus = 'down';
let dbLatencyMs = -1;
const dbStart = Date.now();
try {
// Run simple query to check connection
await prisma.$queryRaw`SELECT 1`;
dbStatus = 'up';
dbLatencyMs = Date.now() - dbStart;
} catch (error) {
// Log error internally but keep health check response structural
console.error('Health check database query failed:', error);
}
const cacheStatus = cacheService.isUsingRedis() ? 'up' : 'up'; // memory fallback is always up
const cacheType = cacheService.isUsingRedis() ? 'redis' : 'memory';
const overallStatus = dbStatus === 'up' ? 'ok' : 'error';
const statusCode = overallStatus === 'ok' ? 200 : 503;
res.status(statusCode).json({
success: overallStatus === 'ok',
status: overallStatus,
timestamp,
uptime: Math.round(uptime * 100) / 100, // round to 2 decimals
memory: {
rss: `${(memoryUsage.rss / 1024 / 1024).toFixed(2)} MB`,
heapTotal: `${(memoryUsage.heapTotal / 1024 / 1024).toFixed(2)} MB`,
heapUsed: `${(memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB`,
},
database: {
status: dbStatus,
latencyMs: dbLatencyMs,
},
cache: {
status: cacheStatus,
type: cacheType,
},
});
}
......@@ -11,11 +11,11 @@ import notificationRoute from '../modules/notifications/notification.route';
import reminderRoute from '../modules/reminders/reminder.route';
import aiAssistantRoute from '../modules/ai-assistant/ai-assistant.route';
import { healthCheck } from './health.controller';
const router = Router();
router.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
router.get('/health', healthCheck);
router.use('/auth', authRoute);
router.use('/users', userRoute);
......
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