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ê ...@@ -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; 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 `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. 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 ## Trạng thái đã biết
- Chưa có test script hoặc test suite trong `package.json`. - 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 - 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`.
lint là verification khả dụng cho tới khi config được bổ sung.
- `env.config.ts` dùng port fallback `8888`, còn `.env.example` dùng `7777`; README - `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. 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 - Wallet, Category, Transaction và Budget đã có API theo ownership; Category đồng
......
...@@ -13,8 +13,13 @@ JWT_REFRESH_SECRET=change_me_refresh_secret ...@@ -13,8 +13,13 @@ JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_ACCESS_EXPIRES_IN=1d JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=redis REDIS_HOST=localhost
REDIS_PORT=6381 REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_ENABLED=true
RATE_LIMIT_MAX_REQUESTS=1000
RATE_LIMIT_WINDOW_MS=900000
MAIL_HOST=smtp.gmail.com MAIL_HOST=smtp.gmail.com
MAIL_PORT=587 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: ...@@ -12,44 +12,50 @@ services:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - 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: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: finwise_redis container_name: finwise_redis
ports: ports:
- "${REDIS_PORT:-6381}:6379" - "${REDIS_PORT:-6381}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
# app: app:
# build: build:
# context: . context: .
# dockerfile: Dockerfile dockerfile: Dockerfile
# container_name: finwise_app container_name: finwise_app
# env_file: restart: always
# - .env ports:
# environment: - "${PORT:-8888}:${PORT:-8888}"
# NODE_ENV: "${NODE_ENV:-development}" env_file:
# PORT: "${PORT:-8888}" - .env
# DB_HOST: postgres environment:
# DB_PORT: "5432" NODE_ENV: "${NODE_ENV:-production}"
# DB_USER: "${DB_USER:-postgres}" DB_HOST: postgres
# DB_PASSWORD: "${DB_PASSWORD:-postgres}" DB_PORT: 5432
# DB_NAME: "${DB_NAME:-datafinwise}" DB_USER: "${DB_USER:-postgres}"
# DATABASE_URL: "postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-datafinwise}?schema=public" DB_PASSWORD: "${DB_PASSWORD:-postgres}"
# JWT_ACCESS_SECRET: "${JWT_ACCESS_SECRET:-default_access_secret}" DB_NAME: "${DB_NAME:-datafinwise}"
# JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET:-default_refresh_secret}" DATABASE_URL: "postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-datafinwise}?schema=public"
# JWT_ACCESS_EXPIRES_IN: "${JWT_ACCESS_EXPIRES_IN:-1d}" REDIS_HOST: redis
# JWT_REFRESH_EXPIRES_IN: "${JWT_REFRESH_EXPIRES_IN:-7d}" REDIS_PORT: 6379
# REDIS_HOST: redis REDIS_ENABLED: "true"
# REDIS_PORT: "6379" depends_on:
# volumes: postgres:
# - ./:/usr/src/app condition: service_healthy
# - /usr/src/app/node_modules redis:
# ports: condition: service_healthy
# - "${PORT:-8888}:${PORT:-8888}"
# depends_on:
# - postgres
# - redis
# command: sh -c "corepack enable && pnpm install && pnpm dev"
volumes: volumes:
postgres_data: 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 @@ ...@@ -27,6 +27,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^6.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^2.2.0", "multer": "^2.2.0",
...@@ -35,6 +36,7 @@ ...@@ -35,6 +36,7 @@
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
...@@ -51,7 +53,8 @@ ...@@ -51,7 +53,8 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2",
"typescript-eslint": "^8.66.0"
}, },
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
......
This diff is collapsed.
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 = { ...@@ -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 = { ...@@ -1818,23 +1818,67 @@ export const swaggerSpec = {
get: { get: {
tags: ['System'], tags: ['System'],
summary: 'Health check', summary: 'Health check',
description: 'Kiểm tra trạng thái hoạt động của Server, Database PostgreSQL và Cache Redis.',
responses: { responses: {
200: { 200: {
description: 'Server đang chạy', description: 'Hệ thống hoạt động bình thường',
content: { content: {
'application/json': { 'application/json': {
schema: { schema: {
type: 'object', type: 'object',
properties: { properties: {
success: { type: 'boolean', example: true },
status: { type: 'string', example: 'ok' }, status: { type: 'string', example: 'ok' },
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' }, timestamp: { type: 'string', format: 'date-time' },
}, uptime: { type: 'number' },
}, memory: { type: 'object' },
}, database: { type: 'object' },
}, cache: { type: 'object' }
}, }
}, }
}, }
}
}
}
}
}, },
'/auth/register': { '/auth/register': {
post: { post: {
......
import { Request, Response, NextFunction } from 'express'; 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 requestCounts = new Map<string, RateLimitRecord>();
const MAX_REQUESTS = 1000; 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 ip = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now(); 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); const record = requestCounts.get(ip);
if (!record || now > record.resetAt) { if (!record || now >= record.resetAt) {
requestCounts.set(ip, { count: 1, resetAt: now + WINDOW_MS }); requestCounts.set(ip, {
count: 1,
resetAt: now + windowMs,
});
next(); next();
return; return;
} }
record.count += 1; 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({ res.status(429).json({
success: false, success: false,
message: 'Too many requests, please try again later', message: 'Too many requests, please try again later',
...@@ -30,3 +88,4 @@ export function rateLimitMiddleware(req: Request, res: Response, next: NextFunct ...@@ -30,3 +88,4 @@ export function rateLimitMiddleware(req: Request, res: Response, next: NextFunct
next(); next();
} }
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { cacheService } from '../../common/services/cache.service';
import { import {
BudgetQueryDto, BudgetQueryDto,
BudgetResponseDto, BudgetResponseDto,
...@@ -44,6 +45,7 @@ export class BudgetService { ...@@ -44,6 +45,7 @@ export class BudgetService {
async create(userId: string, data: CreateBudgetDto) { async create(userId: string, data: CreateBudgetDto) {
const persistence = await this.resolveCreateData(userId, data); const persistence = await this.resolveCreateData(userId, data);
const budget = await this.repository.create(userId, persistence); const budget = await this.repository.create(userId, persistence);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget); return this.toResponse(userId, budget);
} }
...@@ -60,6 +62,7 @@ export class BudgetService { ...@@ -60,6 +62,7 @@ export class BudgetService {
const persistence = await this.resolveUpdateData(userId, current, data); const persistence = await this.resolveUpdateData(userId, current, data);
const budget = await this.repository.update(id, persistence); const budget = await this.repository.update(id, persistence);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget); return this.toResponse(userId, budget);
} }
...@@ -69,6 +72,7 @@ export class BudgetService { ...@@ -69,6 +72,7 @@ export class BudgetService {
? current ? current
: await this.repository.archive(id); : await this.repository.archive(id);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget); return this.toResponse(userId, budget);
} }
...@@ -84,6 +88,7 @@ export class BudgetService { ...@@ -84,6 +88,7 @@ export class BudgetService {
} }
const budget = await this.repository.restore(id); const budget = await this.repository.restore(id);
await this.invalidateReportCache(userId);
return this.toResponse(userId, budget); return this.toResponse(userId, budget);
} }
...@@ -348,4 +353,8 @@ export class BudgetService { ...@@ -348,4 +353,8 @@ export class BudgetService {
return 'ACTIVE'; return 'ACTIVE';
} }
private async invalidateReportCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:reports:${userId}:*`);
}
} }
...@@ -10,15 +10,42 @@ import { ...@@ -10,15 +10,42 @@ import {
CategoryQueryDto, CategoryQueryDto,
} from './category.dto'; } from './category.dto';
import { CategoryRepository } from './category.repository'; import { CategoryRepository } from './category.repository';
import { cacheService } from '../../common/services/cache.service';
export class CategoryService { export class CategoryService {
private readonly repository = new CategoryRepository(); private readonly repository = new CategoryRepository();
findAll(userId: string, query: CategoryQueryDto) { async findAll(userId: string, query: CategoryQueryDto) {
return this.repository.findAll(userId, query); 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) { 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 categories = await this.repository.findAllForTree(userId, query);
const nodes = new Map<string, CategoryTreeNodeDto>(); const nodes = new Map<string, CategoryTreeNodeDto>();
...@@ -41,26 +68,31 @@ export class CategoryService { ...@@ -41,26 +68,31 @@ export class CategoryService {
} }
} }
if (!query.search) { let finalRoots = roots;
return roots; if (query.search) {
} const search = query.search.toLocaleLowerCase();
const prune = (node: CategoryTreeNodeDto): CategoryTreeNodeDto | null => {
const children = node.children
.map(prune)
.filter((child): child is CategoryTreeNodeDto => child !== null);
const search = query.search.toLocaleLowerCase(); if (node.name.toLocaleLowerCase().includes(search) || children.length > 0) {
const prune = (node: CategoryTreeNodeDto): CategoryTreeNodeDto | null => { return { ...node, children };
const children = node.children }
.map(prune)
.filter((child): child is CategoryTreeNodeDto => child !== null);
if (node.name.toLocaleLowerCase().includes(search) || children.length > 0) { return null;
return { ...node, children }; };
}
return null; finalRoots = roots
}; .map(prune)
.filter((node): node is CategoryTreeNodeDto => node !== null);
}
if (isSystemOnly) {
await cacheService.set(cacheKey, finalRoots, 3600); // Cache for 1 hour
}
return roots return finalRoots;
.map(prune)
.filter((node): node is CategoryTreeNodeDto => node !== null);
} }
async findById(userId: string, id: string) { async findById(userId: string, id: string) {
......
...@@ -3,6 +3,8 @@ import { ReminderService } from '../reminders/reminder.service'; ...@@ -3,6 +3,8 @@ import { ReminderService } from '../reminders/reminder.service';
import { NotificationDeliveryService } from './notification-delivery.service'; import { NotificationDeliveryService } from './notification-delivery.service';
import { NotificationService } from './notification.service'; import { NotificationService } from './notification.service';
import { lockService } from '../../common/services/lock.service';
export class NotificationWorker { export class NotificationWorker {
private readonly reminderService = new ReminderService(); private readonly reminderService = new ReminderService();
private readonly notificationService = new NotificationService(); private readonly notificationService = new NotificationService();
...@@ -34,34 +36,46 @@ export class NotificationWorker { ...@@ -34,34 +36,46 @@ export class NotificationWorker {
if (this.running) { if (this.running) {
return; return;
} }
this.running = true;
const now = new Date();
try { const lockKey = 'finwise:lock:notification-worker';
await this.reminderService.processDue(now); const lockTtlMs = 5 * 60 * 1000; // 5 minutes max lock duration
} catch (error) { const lockAcquired = await lockService.acquire(lockKey, lockTtlMs);
console.error('Notification worker failed to process reminders', error);
if (!lockAcquired) {
return;
} }
this.running = true;
const now = new Date();
try { try {
await this.deliveryService.processDue(now); try {
} catch (error) { await this.reminderService.processDue(now);
console.error('Notification worker failed to process deliveries', error); } catch (error) {
} console.error('Notification worker failed to process reminders', error);
}
if (
now.getTime() - this.lastFinancialScanAt
>= envConfig.notifications.financialScanIntervalMs
) {
try { try {
await this.notificationService.scanFinancialAlerts(now); await this.deliveryService.processDue(now);
this.lastFinancialScanAt = now.getTime();
} catch (error) { } catch (error) {
console.error('Notification worker failed to scan financial alerts', error); console.error('Notification worker failed to process deliveries', error);
} }
}
this.running = false; if (
now.getTime() - this.lastFinancialScanAt
>= envConfig.notifications.financialScanIntervalMs
) {
try {
await this.notificationService.scanFinancialAlerts(now);
this.lastFinancialScanAt = now.getTime();
} catch (error) {
console.error('Notification worker failed to scan financial alerts', error);
}
}
} finally {
this.running = false;
await lockService.release(lockKey);
}
} }
} }
......
...@@ -30,6 +30,8 @@ import { ...@@ -30,6 +30,8 @@ import {
ReportWalletRecord, ReportWalletRecord,
} from './report.repository'; } from './report.repository';
import { cacheService } from '../../common/services/cache.service';
const MILLISECONDS_PER_MINUTE = 60 * 1000; const MILLISECONDS_PER_MINUTE = 60 * 1000;
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const MAX_CUSTOM_RANGE_DAYS = 5 * 366; const MAX_CUSTOM_RANGE_DAYS = 5 * 366;
...@@ -51,6 +53,12 @@ export class ReportService { ...@@ -51,6 +53,12 @@ export class ReportService {
private readonly repository = new ReportRepository(); private readonly repository = new ReportRepository();
async getOverview(userId: string, query: ReportQueryDto): Promise<FinancialOverviewDto> { 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 period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query); const scope = await this.resolveScope(userId, query);
const [transactions, budgets, goals] = await Promise.all([ const [transactions, budgets, goals] = await Promise.all([
...@@ -76,7 +84,7 @@ export class ReportService { ...@@ -76,7 +84,7 @@ export class ReportService {
const flows = this.toMoneyFlows(transactions, knownCurrencies); const flows = this.toMoneyFlows(transactions, knownCurrencies);
const currentBalances = this.sumWalletBalances(scope.wallets); const currentBalances = this.sumWalletBalances(scope.wallets);
return { const result = {
period, period,
metricsByCurrency: flows.map((flow) => { metricsByCurrency: flows.map((flow) => {
const income = new Prisma.Decimal(flow.income); const income = new Prisma.Decimal(flow.income);
...@@ -109,9 +117,18 @@ export class ReportService { ...@@ -109,9 +117,18 @@ export class ReportService {
periodContributions, periodContributions,
), ),
}; };
await cacheService.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
} }
async getCashFlow(userId: string, query: ReportQueryDto): Promise<CashFlowReportDto> { 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 period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query); const scope = await this.resolveScope(userId, query);
const transactions = await this.repository.findTransactions( const transactions = await this.repository.findTransactions(
...@@ -137,7 +154,7 @@ export class ReportService { ...@@ -137,7 +154,7 @@ export class ReportService {
transactionsByBucket.set(key, bucket); transactionsByBucket.set(key, bucket);
}); });
return { const result = {
period, period,
granularity, granularity,
totalsByCurrency: this.toMoneyFlows(transactions, knownCurrencies), totalsByCurrency: this.toMoneyFlows(transactions, knownCurrencies),
...@@ -150,12 +167,21 @@ export class ReportService { ...@@ -150,12 +167,21 @@ export class ReportService {
), ),
})), })),
}; };
await cacheService.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
} }
async getSpendingByCategory( async getSpendingByCategory(
userId: string, userId: string,
query: ReportQueryDto, query: ReportQueryDto,
): Promise<SpendingCategoryReportDto> { ): 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 period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query); const scope = await this.resolveScope(userId, query);
const transactions = await this.repository.findTransactions( const transactions = await this.repository.findTransactions(
...@@ -170,7 +196,7 @@ export class ReportService { ...@@ -170,7 +196,7 @@ export class ReportService {
); );
const currencies = this.getKnownCurrencies(scope.wallets, expenses, scope.currency); const currencies = this.getKnownCurrencies(scope.wallets, expenses, scope.currency);
return { const result = {
period, period,
currencies: currencies.map((currency) => { currencies: currencies.map((currency) => {
const currencyExpenses = expenses.filter( const currencyExpenses = expenses.filter(
...@@ -213,12 +239,21 @@ export class ReportService { ...@@ -213,12 +239,21 @@ export class ReportService {
}; };
}), }),
}; };
await cacheService.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
} }
async getBudgetPerformance( async getBudgetPerformance(
userId: string, userId: string,
query: ReportQueryDto, query: ReportQueryDto,
): Promise<BudgetPerformanceReportDto> { ): 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 period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query); const scope = await this.resolveScope(userId, query);
const [transactions, budgets] = await Promise.all([ const [transactions, budgets] = await Promise.all([
...@@ -232,11 +267,14 @@ export class ReportService { ...@@ -232,11 +267,14 @@ export class ReportService {
this.repository.findBudgets(userId, period.from, period.to, scope.currency), this.repository.findBudgets(userId, period.from, period.to, scope.currency),
]); ]);
return { const result = {
period, period,
summary: this.buildBudgetSummary(budgets, transactions, period), summary: this.buildBudgetSummary(budgets, transactions, period),
budgets: this.buildBudgetItems(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> { private async resolveScope(userId: string, query: ReportQueryDto): Promise<ResolvedScope> {
......
import { Prisma, SavingGoalStatus } from '@prisma/client'; import { Prisma, SavingGoalStatus } from '@prisma/client';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { cacheService } from '../../common/services/cache.service';
import { import {
CreateSavingContributionDto, CreateSavingContributionDto,
CreateSavingGoalDto, CreateSavingGoalDto,
...@@ -49,11 +50,12 @@ export class SavingGoalService { ...@@ -49,11 +50,12 @@ export class SavingGoalService {
async create(userId: string, data: CreateSavingGoalDto) { async create(userId: string, data: CreateSavingGoalDto) {
const goal = await this.repository.create(userId, data); const goal = await this.repository.create(userId, data);
await this.invalidateReportCache(userId);
return this.toResponse(goal, this.emptySummary(goal.id)); return this.toResponse(goal, this.emptySummary(goal.id));
} }
update(userId: string, id: string, data: UpdateSavingGoalDto) { async update(userId: string, id: string, data: UpdateSavingGoalDto) {
return this.repository.runSerializable(async (transaction) => { const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction); const current = await this.findRecord(userId, id, transaction);
this.ensureMutable(current); this.ensureMutable(current);
const summary = await this.repository.findSummary(id, transaction); const summary = await this.repository.findSummary(id, transaction);
...@@ -88,10 +90,13 @@ export class SavingGoalService { ...@@ -88,10 +90,13 @@ export class SavingGoalService {
return this.toResponse(goal, summary); return this.toResponse(goal, summary);
}); });
await this.invalidateReportCache(userId);
return result;
} }
archive(userId: string, id: string) { async archive(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => { const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction); const current = await this.findRecord(userId, id, transaction);
const goal = current.isArchived const goal = current.isArchived
? current ? current
...@@ -100,10 +105,13 @@ export class SavingGoalService { ...@@ -100,10 +105,13 @@ export class SavingGoalService {
return this.toResponse(goal, summary); return this.toResponse(goal, summary);
}); });
await this.invalidateReportCache(userId);
return result;
} }
restore(userId: string, id: string) { async restore(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => { const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction); const current = await this.findRecord(userId, id, transaction);
const summary = await this.repository.findSummary(id, transaction); const summary = await this.repository.findSummary(id, transaction);
...@@ -125,6 +133,9 @@ export class SavingGoalService { ...@@ -125,6 +133,9 @@ export class SavingGoalService {
return this.toResponse(goal, summary); return this.toResponse(goal, summary);
}); });
await this.invalidateReportCache(userId);
return result;
} }
async findContributions( async findContributions(
...@@ -136,12 +147,12 @@ export class SavingGoalService { ...@@ -136,12 +147,12 @@ export class SavingGoalService {
return this.repository.findContributions(savingGoalId, query); return this.repository.findContributions(savingGoalId, query);
} }
createContribution( async createContribution(
userId: string, userId: string,
savingGoalId: string, savingGoalId: string,
data: CreateSavingContributionDto, data: CreateSavingContributionDto,
) { ) {
return this.repository.runSerializable(async (transaction) => { const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction); const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureCanContribute(current); this.ensureCanContribute(current);
const contribution = await this.repository.createContribution( const contribution = await this.repository.createContribution(
...@@ -159,15 +170,18 @@ export class SavingGoalService { ...@@ -159,15 +170,18 @@ export class SavingGoalService {
goal: this.toResponse(goal, summary), goal: this.toResponse(goal, summary),
}; };
}); });
await this.invalidateReportCache(userId);
return result;
} }
updateContribution( async updateContribution(
userId: string, userId: string,
savingGoalId: string, savingGoalId: string,
contributionId: string, contributionId: string,
data: UpdateSavingContributionDto, data: UpdateSavingContributionDto,
) { ) {
return this.repository.runSerializable(async (transaction) => { const result = await this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction); const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureMutable(current); this.ensureMutable(current);
await this.findContributionRecord( await this.findContributionRecord(
...@@ -190,14 +204,17 @@ export class SavingGoalService { ...@@ -190,14 +204,17 @@ export class SavingGoalService {
goal: this.toResponse(goal, summary), goal: this.toResponse(goal, summary),
}; };
}); });
await this.invalidateReportCache(userId);
return result;
} }
deleteContribution( async deleteContribution(
userId: string, userId: string,
savingGoalId: string, savingGoalId: string,
contributionId: 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); const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureMutable(current); this.ensureMutable(current);
await this.findContributionRecord( await this.findContributionRecord(
...@@ -219,6 +236,9 @@ export class SavingGoalService { ...@@ -219,6 +236,9 @@ export class SavingGoalService {
goal: this.toResponse(goal, summary), goal: this.toResponse(goal, summary),
}; };
}); });
await this.invalidateReportCache(userId);
return result;
} }
private async findRecord( private async findRecord(
...@@ -371,4 +391,7 @@ export class SavingGoalService { ...@@ -371,4 +391,7 @@ export class SavingGoalService {
lastContributionAt: null, 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'; ...@@ -3,6 +3,7 @@ import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { NotificationService } from '../notifications/notification.service'; import { NotificationService } from '../notifications/notification.service';
import { ReceiptFileService, StoredReceipt } from './receipt-file.service'; import { ReceiptFileService, StoredReceipt } from './receipt-file.service';
import { cacheService } from '../../common/services/cache.service';
import { import {
CreateTransactionDto, CreateTransactionDto,
TransactionQueryDto, TransactionQueryDto,
...@@ -55,6 +56,7 @@ export class TransactionService { ...@@ -55,6 +56,7 @@ export class TransactionService {
}); });
await this.notificationService.detectUnusualTransaction(userId, created); await this.notificationService.detectUnusualTransaction(userId, created);
await this.invalidateReportCache(userId);
return created; return created;
} }
...@@ -110,6 +112,7 @@ export class TransactionService { ...@@ -110,6 +112,7 @@ export class TransactionService {
}); });
await this.notificationService.detectUnusualTransaction(userId, updated); await this.notificationService.detectUnusualTransaction(userId, updated);
await this.invalidateReportCache(userId);
return updated; return updated;
} }
...@@ -141,6 +144,7 @@ export class TransactionService { ...@@ -141,6 +144,7 @@ export class TransactionService {
}); });
await this.receiptFiles.remove(deleted.receiptUrl); await this.receiptFiles.remove(deleted.receiptUrl);
await this.invalidateReportCache(userId);
return { id: deleted.id }; return { id: deleted.id };
} }
...@@ -159,6 +163,7 @@ export class TransactionService { ...@@ -159,6 +163,7 @@ export class TransactionService {
} }
await this.receiptFiles.remove(result.previousReceiptKey); await this.receiptFiles.remove(result.previousReceiptKey);
await this.invalidateReportCache(userId);
return result.transaction; return result.transaction;
} catch (error) { } catch (error) {
await this.receiptFiles.remove(receiptKey); await this.receiptFiles.remove(receiptKey);
...@@ -191,6 +196,7 @@ export class TransactionService { ...@@ -191,6 +196,7 @@ export class TransactionService {
} }
await this.receiptFiles.remove(result.previousReceiptKey); await this.receiptFiles.remove(result.previousReceiptKey);
await this.invalidateReportCache(userId);
return result.transaction; return result.transaction;
} }
...@@ -238,4 +244,8 @@ export class TransactionService { ...@@ -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'; ...@@ -3,6 +3,7 @@ import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { CreateWalletDto, UpdateWalletDto, WalletQueryDto } from './wallet.dto'; import { CreateWalletDto, UpdateWalletDto, WalletQueryDto } from './wallet.dto';
import { WalletRepository } from './wallet.repository'; import { WalletRepository } from './wallet.repository';
import { cacheService } from '../../common/services/cache.service';
export class WalletService { export class WalletService {
private readonly repository = new WalletRepository(); private readonly repository = new WalletRepository();
...@@ -25,7 +26,9 @@ export class WalletService { ...@@ -25,7 +26,9 @@ export class WalletService {
await this.ensureUniqueName(userId, data.name); await this.ensureUniqueName(userId, data.name);
try { try {
return await this.repository.create(userId, data); const wallet = await this.repository.create(userId, data);
await this.invalidateReportCache(userId);
return wallet;
} catch (error) { } catch (error) {
this.handleUniqueConstraint(error); this.handleUniqueConstraint(error);
throw error; throw error;
...@@ -40,7 +43,9 @@ export class WalletService { ...@@ -40,7 +43,9 @@ export class WalletService {
} }
try { try {
return await this.repository.update(id, data); const wallet = await this.repository.update(id, data);
await this.invalidateReportCache(userId);
return wallet;
} catch (error) { } catch (error) {
this.handleUniqueConstraint(error); this.handleUniqueConstraint(error);
throw error; throw error;
...@@ -64,6 +69,7 @@ export class WalletService { ...@@ -64,6 +69,7 @@ export class WalletService {
throw new AppError('Archived wallet cannot be set as default', 409, ERROR_CODE.WALLET_ARCHIVED); throw new AppError('Archived wallet cannot be set as default', 409, ERROR_CODE.WALLET_ARCHIVED);
} }
await this.invalidateReportCache(userId);
return updatedWallet; return updatedWallet;
} }
...@@ -92,6 +98,7 @@ export class WalletService { ...@@ -92,6 +98,7 @@ export class WalletService {
); );
} }
await this.invalidateReportCache(userId);
return archivedWallet; return archivedWallet;
} }
...@@ -102,7 +109,9 @@ export class WalletService { ...@@ -102,7 +109,9 @@ export class WalletService {
return wallet; 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) { private async ensureUniqueName(userId: string, name: string, excludeId?: string) {
...@@ -118,4 +127,8 @@ export class WalletService { ...@@ -118,4 +127,8 @@ export class WalletService {
throw new AppError('Wallet name already exists', 409, ERROR_CODE.DUPLICATE_ENTRY); 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'; ...@@ -11,11 +11,11 @@ import notificationRoute from '../modules/notifications/notification.route';
import reminderRoute from '../modules/reminders/reminder.route'; import reminderRoute from '../modules/reminders/reminder.route';
import aiAssistantRoute from '../modules/ai-assistant/ai-assistant.route'; import aiAssistantRoute from '../modules/ai-assistant/ai-assistant.route';
import { healthCheck } from './health.controller';
const router = Router(); const router = Router();
router.get('/health', (req, res) => { router.get('/health', healthCheck);
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
router.use('/auth', authRoute); router.use('/auth', authRoute);
router.use('/users', userRoute); 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