fix: apply consistent-type-imports across API codebase (728 lint errors)
- Convert `import type { X }` to `import { type X }` (inline-type-imports style)
- Suppress consistent-type-imports for `typeof import()` in instrument.ts
- Includes uncommitted agent work: metrics module, redis caching, audit logs,
saved searches, circuit breaker, rate limiting, and admin enhancements
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -8,10 +8,12 @@ describe('CacheService', () => {
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
getClient: ReturnType<typeof vi.fn>;
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
let mockHitCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockMissCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockDegradationCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = {
|
||||
@@ -22,16 +24,19 @@ describe('CacheService', () => {
|
||||
scan: vi.fn().mockResolvedValue(['0', []]),
|
||||
del: vi.fn(),
|
||||
}),
|
||||
isAvailable: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
mockHitCounter = { inc: vi.fn() };
|
||||
mockMissCounter = { inc: vi.fn() };
|
||||
mockDegradationCounter = { inc: vi.fn() };
|
||||
|
||||
cacheService = new CacheService(
|
||||
mockRedis as any,
|
||||
mockLogger as any,
|
||||
mockHitCounter as any,
|
||||
mockMissCounter as any,
|
||||
mockDegradationCounter as any,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,6 +96,39 @@ describe('CacheService', () => {
|
||||
|
||||
await expect(cacheService.getOrSet('key', loader, 60, 'listing')).rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
it('should skip Redis and call loader directly when Redis is unavailable', async () => {
|
||||
mockRedis.isAvailable.mockReturnValue(false);
|
||||
const data = { id: 'direct' };
|
||||
const loader = vi.fn().mockResolvedValue(data);
|
||||
|
||||
const result = await cacheService.getOrSet('key', loader, 60, 'listing');
|
||||
|
||||
expect(result).toEqual(data);
|
||||
expect(mockRedis.get).not.toHaveBeenCalled();
|
||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'listing', operation: 'skip_unavailable' });
|
||||
expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
|
||||
});
|
||||
|
||||
it('should track degradation on cache read error', async () => {
|
||||
mockRedis.get.mockRejectedValue(new Error('connection lost'));
|
||||
const loader = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
await cacheService.getOrSet('key', loader, 60, 'search');
|
||||
|
||||
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'read_error' });
|
||||
});
|
||||
|
||||
it('should track degradation on cache write error', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockRejectedValue(new Error('write error'));
|
||||
const loader = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
await cacheService.getOrSet('key', loader, 60, 'search');
|
||||
|
||||
expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'write_error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { CircuitBreaker, CircuitOpenError, CircuitState } from '../circuit-breaker';
|
||||
|
||||
describe('CircuitBreaker', () => {
|
||||
let breaker: CircuitBreaker;
|
||||
let stateChanges: Array<{ from: CircuitState; to: CircuitState }>;
|
||||
|
||||
beforeEach(() => {
|
||||
stateChanges = [];
|
||||
breaker = new CircuitBreaker({
|
||||
name: 'test-service',
|
||||
failureThreshold: 3,
|
||||
resetTimeMs: 100, // fast for tests
|
||||
onStateChange: (from, to) => stateChanges.push({ from, to }),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts in CLOSED state', () => {
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('stays CLOSED when failures are below threshold', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('opens after reaching failure threshold', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
expect(breaker.isAvailable()).toBe(false);
|
||||
expect(stateChanges).toEqual([{ from: CircuitState.CLOSED, to: CircuitState.OPEN }]);
|
||||
});
|
||||
|
||||
it('rejects calls immediately when OPEN', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await expect(breaker.exec(() => Promise.resolve('ok'))).rejects.toThrow(CircuitOpenError);
|
||||
});
|
||||
|
||||
it('transitions to HALF_OPEN after reset timeout', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
|
||||
// Wait for the reset timeout
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('closes on successful probe in HALF_OPEN', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
|
||||
|
||||
const result = await breaker.exec(() => Promise.resolve('recovered'));
|
||||
expect(result).toBe('recovered');
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('re-opens on failed probe in HALF_OPEN', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(breaker.getState()).toBe(CircuitState.HALF_OPEN);
|
||||
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
});
|
||||
|
||||
it('resets failure count on success', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const succeedingFn = vi.fn().mockResolvedValue('ok');
|
||||
|
||||
// 2 failures, then success, then 2 more failures → should still be CLOSED
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(succeedingFn);
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('manual reset() brings breaker back to CLOSED', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
|
||||
breaker.reset();
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
expect(breaker.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('recordSuccess() transitions from OPEN to CLOSED after timeout', async () => {
|
||||
const failingFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await breaker.exec(failingFn).catch(() => {});
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
breaker.recordSuccess();
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
});
|
||||
|
||||
it('recordFailure() increments failures', () => {
|
||||
breaker.recordFailure();
|
||||
breaker.recordFailure();
|
||||
expect(breaker.getState()).toBe(CircuitState.CLOSED);
|
||||
|
||||
breaker.recordFailure();
|
||||
expect(breaker.getState()).toBe(CircuitState.OPEN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { HttpException, HttpStatus, type ExecutionContext } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
UserRateLimitGuard,
|
||||
DEFAULT_ROLE_LIMITS,
|
||||
DEFAULT_WINDOW_SECONDS,
|
||||
} from '../guards/user-rate-limit.guard';
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockRedis(overrides: Partial<{ evalResult: [number, number]; throwError: boolean }> = {}) {
|
||||
const evalFn = overrides.throwError
|
||||
? vi.fn().mockRejectedValue(new Error('Redis connection lost'))
|
||||
: vi.fn().mockResolvedValue(overrides.evalResult ?? [1, 60]);
|
||||
|
||||
return {
|
||||
getClient: vi.fn().mockReturnValue({ eval: evalFn }),
|
||||
isAvailable: vi.fn().mockReturnValue(!overrides.throwError),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
onModuleDestroy: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockLogger() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockReflector(options?: any) {
|
||||
return {
|
||||
getAllAndOverride: vi.fn().mockReturnValue(options ?? undefined),
|
||||
} as unknown as Reflector;
|
||||
}
|
||||
|
||||
interface MockContextOptions {
|
||||
user?: { sub: string; role: string } | null;
|
||||
handler?: string;
|
||||
controller?: string;
|
||||
}
|
||||
|
||||
function buildContext(opts: MockContextOptions = {}): ExecutionContext {
|
||||
const headers: Record<string, string> = {};
|
||||
const response = { setHeader: vi.fn() };
|
||||
const user = 'user' in opts ? opts.user : { sub: 'user-123', role: 'BUYER' };
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
user,
|
||||
headers,
|
||||
}),
|
||||
getResponse: () => response,
|
||||
}),
|
||||
getHandler: () => ({ name: opts.handler ?? 'testHandler' }),
|
||||
getClass: () => ({ name: opts.controller ?? 'TestController' }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('UserRateLimitGuard', () => {
|
||||
it('allows request when user is within rate limit', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, 60] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(buildContext());
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('passes through for unauthenticated requests (no user)', async () => {
|
||||
const redis = mockRedis();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(buildContext({ user: null }));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('passes through for requests without user.sub', async () => {
|
||||
const redis = mockRedis();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(
|
||||
buildContext({ user: { sub: '', role: 'BUYER' } }),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects request with 429 when rate limit exceeded', async () => {
|
||||
const redis = mockRedis({ evalResult: [101, 45] }); // 101 > BUYER limit of 100
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const ctx = buildContext();
|
||||
await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException);
|
||||
|
||||
try {
|
||||
await guard.canActivate(ctx);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpException);
|
||||
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
const body = (error as HttpException).getResponse();
|
||||
expect(body).toMatchObject({
|
||||
statusCode: 429,
|
||||
retryAfter: 45,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('sets rate limit headers on the response', async () => {
|
||||
const redis = mockRedis({ evalResult: [50, 30] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
const ctx = buildContext();
|
||||
|
||||
await guard.canActivate(ctx);
|
||||
|
||||
const response = ctx.switchToHttp().getResponse();
|
||||
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', DEFAULT_ROLE_LIMITS.BUYER);
|
||||
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 50); // 100 - 50
|
||||
expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', 30);
|
||||
});
|
||||
|
||||
it('sets Retry-After header when limit exceeded', async () => {
|
||||
const redis = mockRedis({ evalResult: [201, 42] }); // Exceeds AGENT limit of 200
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
const ctx = buildContext({ user: { sub: 'agent-1', role: 'AGENT' } });
|
||||
|
||||
try {
|
||||
await guard.canActivate(ctx);
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
const response = ctx.switchToHttp().getResponse();
|
||||
expect(response.setHeader).toHaveBeenCalledWith('Retry-After', 42);
|
||||
});
|
||||
|
||||
it('uses role-specific limits (ADMIN gets 500)', async () => {
|
||||
const redis = mockRedis({ evalResult: [450, 20] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(
|
||||
buildContext({ user: { sub: 'admin-1', role: 'ADMIN' } }),
|
||||
);
|
||||
expect(result).toBe(true); // 450 < 500 ADMIN limit
|
||||
});
|
||||
|
||||
it('uses role-specific limits (AGENT gets 200)', async () => {
|
||||
const redis = mockRedis({ evalResult: [199, 10] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
const result = await guard.canActivate(
|
||||
buildContext({ user: { sub: 'agent-1', role: 'AGENT' } }),
|
||||
);
|
||||
expect(result).toBe(true); // 199 < 200 AGENT limit
|
||||
});
|
||||
|
||||
it('uses per-route overrides from @UserRateLimit decorator', async () => {
|
||||
const redis = mockRedis({ evalResult: [51, 30] });
|
||||
// Override BUYER limit to 50 for this route
|
||||
const reflector = mockReflector({ limits: { BUYER: 50 }, windowSeconds: 120 });
|
||||
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
|
||||
|
||||
await expect(guard.canActivate(buildContext())).rejects.toThrow(HttpException);
|
||||
});
|
||||
|
||||
it('passes Redis window seconds argument correctly', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, 120] });
|
||||
const reflector = mockReflector({ windowSeconds: 120 });
|
||||
const guard = new UserRateLimitGuard(redis, reflector, mockLogger());
|
||||
|
||||
await guard.canActivate(buildContext());
|
||||
|
||||
const evalCall = redis.getClient().eval.mock.calls[0];
|
||||
expect(evalCall[3]).toBe(120); // windowSeconds arg passed to Lua
|
||||
});
|
||||
|
||||
it('uses correct Redis key format with user ID', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, 60] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
await guard.canActivate(buildContext({ user: { sub: 'user-xyz', role: 'BUYER' } }));
|
||||
|
||||
const evalCall = redis.getClient().eval.mock.calls[0];
|
||||
expect(evalCall[2]).toBe('rate_limit:user:user-xyz');
|
||||
});
|
||||
|
||||
it('fails open when Redis throws an error', async () => {
|
||||
const redis = mockRedis({ throwError: true });
|
||||
const logger = mockLogger();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
|
||||
|
||||
const result = await guard.canActivate(buildContext());
|
||||
expect(result).toBe(true);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('User rate limit check failed'),
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs warning when rate limit exceeded', async () => {
|
||||
const redis = mockRedis({ evalResult: [101, 55] });
|
||||
const logger = mockLogger();
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), logger);
|
||||
|
||||
try {
|
||||
await guard.canActivate(buildContext());
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('User rate limit exceeded'),
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses DEFAULT_WINDOW_SECONDS when no override provided', async () => {
|
||||
const redis = mockRedis({ evalResult: [1, DEFAULT_WINDOW_SECONDS] });
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
await guard.canActivate(buildContext());
|
||||
|
||||
const evalCall = redis.getClient().eval.mock.calls[0];
|
||||
expect(evalCall[3]).toBe(DEFAULT_WINDOW_SECONDS);
|
||||
});
|
||||
|
||||
it('defaults to BUYER limit for unknown roles', async () => {
|
||||
const redis = mockRedis({ evalResult: [101, 30] }); // > BUYER's 100
|
||||
const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
|
||||
|
||||
// Force an unexpected role value
|
||||
await expect(
|
||||
guard.canActivate(buildContext({ user: { sub: 'u1', role: 'UNKNOWN_ROLE' } })),
|
||||
).rejects.toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||
import { type Counter } from 'prom-client';
|
||||
import { type LoggerService } from './logger.service';
|
||||
import { type RedisService } from './redis.service';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { Counter } from 'prom-client';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { LoggerService } from './logger.service';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
export const CACHE_HIT_TOTAL = 'cache_hit_total';
|
||||
export const CACHE_MISS_TOTAL = 'cache_miss_total';
|
||||
export const CACHE_DEGRADATION_TOTAL = 'cache_degradation_total';
|
||||
|
||||
export const CacheTTL = {
|
||||
/** Listing detail — moderate TTL, invalidated on mutation */
|
||||
@@ -52,6 +56,7 @@ export class CacheService implements OnModuleInit {
|
||||
private readonly logger: LoggerService,
|
||||
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
|
||||
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
|
||||
@InjectMetric(CACHE_DEGRADATION_TOTAL) private readonly cacheDegradationCounter: Counter,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -60,6 +65,9 @@ export class CacheService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Cache-aside: get from cache, or execute loader and store result.
|
||||
*
|
||||
* When Redis is down the loader is called directly (graceful degradation).
|
||||
* Degradation events are counted via `cache_degradation_total` for alerting.
|
||||
*/
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
@@ -67,6 +75,13 @@ export class CacheService implements OnModuleInit {
|
||||
ttlSeconds: number,
|
||||
resource: string,
|
||||
): Promise<T> {
|
||||
// Fast-path: skip Redis entirely when it is known to be disconnected.
|
||||
if (!this.redis.isAvailable()) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'skip_unavailable' });
|
||||
this.cacheMissCounter.inc({ resource });
|
||||
return loader();
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await this.redis.get(key);
|
||||
if (cached !== null) {
|
||||
@@ -74,6 +89,7 @@ export class CacheService implements OnModuleInit {
|
||||
return JSON.parse(cached) as T;
|
||||
}
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'read_error' });
|
||||
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
|
||||
@@ -83,6 +99,7 @@ export class CacheService implements OnModuleInit {
|
||||
try {
|
||||
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource, operation: 'write_error' });
|
||||
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
|
||||
@@ -94,6 +111,7 @@ export class CacheService implements OnModuleInit {
|
||||
try {
|
||||
await this.redis.del(key);
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'invalidate_error' });
|
||||
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
}
|
||||
@@ -111,6 +129,7 @@ export class CacheService implements OnModuleInit {
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
} catch (err) {
|
||||
this.cacheDegradationCounter.inc({ resource: 'invalidation', operation: 'prefix_invalidate_error' });
|
||||
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
|
||||
}
|
||||
}
|
||||
|
||||
147
apps/api/src/modules/shared/infrastructure/circuit-breaker.ts
Normal file
147
apps/api/src/modules/shared/infrastructure/circuit-breaker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Lightweight circuit breaker for external service calls.
|
||||
*
|
||||
* States:
|
||||
* CLOSED → normal operation; failures increment counter
|
||||
* OPEN → calls fail fast; after `resetTimeMs` → HALF_OPEN
|
||||
* HALF_OPEN → one probe call allowed; success → CLOSED, failure → OPEN
|
||||
*
|
||||
* Does NOT depend on any NestJS or framework-specific API so it can
|
||||
* be used as a plain utility across the codebase.
|
||||
*/
|
||||
|
||||
export enum CircuitState {
|
||||
CLOSED = 'CLOSED',
|
||||
OPEN = 'OPEN',
|
||||
HALF_OPEN = 'HALF_OPEN',
|
||||
}
|
||||
|
||||
export interface CircuitBreakerOptions {
|
||||
/** Human-readable service name — used in logs / metrics. */
|
||||
name: string;
|
||||
/** Number of consecutive failures before opening the circuit. Default 5. */
|
||||
failureThreshold?: number;
|
||||
/** Time in ms to wait before transitioning from OPEN → HALF_OPEN. Default 30 000 (30s). */
|
||||
resetTimeMs?: number;
|
||||
/** Optional callback fired on every state transition. */
|
||||
onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
|
||||
}
|
||||
|
||||
export class CircuitBreaker {
|
||||
readonly name: string;
|
||||
|
||||
private state = CircuitState.CLOSED;
|
||||
private failureCount = 0;
|
||||
private lastFailureTime = 0;
|
||||
|
||||
private readonly failureThreshold: number;
|
||||
private readonly resetTimeMs: number;
|
||||
private readonly onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
|
||||
|
||||
constructor(options: CircuitBreakerOptions) {
|
||||
this.name = options.name;
|
||||
this.failureThreshold = options.failureThreshold ?? 5;
|
||||
this.resetTimeMs = options.resetTimeMs ?? 30_000;
|
||||
this.onStateChange = options.onStateChange;
|
||||
}
|
||||
|
||||
/** Current state of the circuit. */
|
||||
getState(): CircuitState {
|
||||
this.evaluateState();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Whether the circuit allows a call to pass through. */
|
||||
isAvailable(): boolean {
|
||||
this.evaluateState();
|
||||
return this.state !== CircuitState.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute `fn` through the breaker.
|
||||
*
|
||||
* - CLOSED / HALF_OPEN → `fn` is called.
|
||||
* - OPEN → immediately throws `CircuitOpenError`.
|
||||
*
|
||||
* On success the breaker resets to CLOSED.
|
||||
* On failure the breaker increments the failure counter (CLOSED)
|
||||
* or re-opens (HALF_OPEN).
|
||||
*/
|
||||
async exec<T>(fn: () => Promise<T>): Promise<T> {
|
||||
this.evaluateState();
|
||||
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
throw new CircuitOpenError(this.name);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.onFailure();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a manual success (e.g. from a health-check probe). */
|
||||
recordSuccess(): void {
|
||||
this.onSuccess();
|
||||
}
|
||||
|
||||
/** Record a manual failure. */
|
||||
recordFailure(): void {
|
||||
this.onFailure();
|
||||
}
|
||||
|
||||
/** Force-reset the breaker to CLOSED. */
|
||||
reset(): void {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
this.failureCount = 0;
|
||||
this.lastFailureTime = 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private evaluateState(): void {
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
const elapsed = Date.now() - this.lastFailureTime;
|
||||
if (elapsed >= this.resetTimeMs) {
|
||||
this.transitionTo(CircuitState.HALF_OPEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onSuccess(): void {
|
||||
if (this.state !== CircuitState.CLOSED) {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
}
|
||||
this.failureCount = 0;
|
||||
}
|
||||
|
||||
private onFailure(): void {
|
||||
this.failureCount++;
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
} else if (this.failureCount >= this.failureThreshold) {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
}
|
||||
}
|
||||
|
||||
private transitionTo(next: CircuitState): void {
|
||||
if (this.state === next) return;
|
||||
const prev = this.state;
|
||||
this.state = next;
|
||||
this.onStateChange?.(prev, next, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a call is attempted while the circuit is OPEN. */
|
||||
export class CircuitOpenError extends Error {
|
||||
constructor(public readonly serviceName: string) {
|
||||
super(`Circuit breaker OPEN for service: ${serviceName}`);
|
||||
this.name = 'CircuitOpenError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { type UserRole } from '@prisma/client';
|
||||
import { USER_RATE_LIMIT_KEY, type UserRateLimitOptions } from '../guards/user-rate-limit.guard';
|
||||
|
||||
/**
|
||||
* Decorator to override per-user rate limits for a specific route or controller.
|
||||
*
|
||||
* When not applied, the UserRateLimitGuard uses DEFAULT_ROLE_LIMITS.
|
||||
*
|
||||
* @example
|
||||
* // Override BUYER limit to 50 req/min for an expensive endpoint
|
||||
* @UserRateLimit({ limits: { BUYER: 50 }, windowSeconds: 60 })
|
||||
*
|
||||
* @example
|
||||
* // Allow higher limits for a specific route
|
||||
* @UserRateLimit({ limits: { BUYER: 200, AGENT: 400 } })
|
||||
*/
|
||||
export const UserRateLimit = (options: {
|
||||
limits?: Partial<Record<UserRole, number>>;
|
||||
windowSeconds?: number;
|
||||
}) => SetMetadata<string, UserRateLimitOptions>(USER_RATE_LIMIT_KEY, options);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type EventEmitter2 } from '@nestjs/event-emitter';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { type DomainEvent } from '../domain/domain-event';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Request, Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception';
|
||||
import { ErrorCode } from '../../domain/error-codes';
|
||||
import { type LoggerService } from '../logger.service';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import type { Request } from 'express';
|
||||
import { type Request } from 'express';
|
||||
|
||||
/**
|
||||
* Extends ThrottlerGuard to extract real client IP behind reverse proxies
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Injectable,
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { type Reflector } from '@nestjs/core';
|
||||
import { type UserRole } from '@prisma/client';
|
||||
import { type LoggerService } from '../logger.service';
|
||||
import { type RedisService } from '../redis.service';
|
||||
|
||||
/**
|
||||
* Role-based rate limits (requests per window).
|
||||
* Applied only to authenticated routes that use JwtAuthGuard.
|
||||
*/
|
||||
export const DEFAULT_ROLE_LIMITS: Record<UserRole, number> = {
|
||||
BUYER: 100,
|
||||
SELLER: 150,
|
||||
AGENT: 200,
|
||||
ADMIN: 500,
|
||||
};
|
||||
|
||||
/** Default sliding window in seconds. */
|
||||
export const DEFAULT_WINDOW_SECONDS = 60;
|
||||
|
||||
/** Metadata key for per-route overrides via @UserRateLimit decorator. */
|
||||
export const USER_RATE_LIMIT_KEY = 'user_rate_limit';
|
||||
|
||||
export interface UserRateLimitOptions {
|
||||
/** Override limits per role for this route. Roles not listed use DEFAULT_ROLE_LIMITS. */
|
||||
limits?: Partial<Record<UserRole, number>>;
|
||||
/** Window in seconds (default 60). */
|
||||
windowSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard that enforces per-user (by user ID) rate limiting for authenticated routes.
|
||||
*
|
||||
* Uses a Redis sliding-window counter keyed by `rate_limit:user:{userId}`.
|
||||
* Falls back to allowing the request if Redis is unavailable (fail-open)
|
||||
* to avoid blocking legitimate traffic during cache outages.
|
||||
*
|
||||
* This guard checks `request.user` — it must run AFTER JwtAuthGuard.
|
||||
* If no authenticated user is found, the guard passes (unauthenticated
|
||||
* routes are handled by the existing IP-based ThrottlerBehindProxyGuard).
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserRateLimitGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly reflector: Reflector,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// Skip for unauthenticated requests — IP-based throttler handles those
|
||||
if (!user?.sub || !user?.role) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userId: string = user.sub;
|
||||
const role: UserRole = user.role;
|
||||
|
||||
// Resolve per-route overrides
|
||||
const options = this.reflector.getAllAndOverride<UserRateLimitOptions | undefined>(
|
||||
USER_RATE_LIMIT_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
const windowSeconds = options?.windowSeconds ?? DEFAULT_WINDOW_SECONDS;
|
||||
const limit = options?.limits?.[role] ?? DEFAULT_ROLE_LIMITS[role] ?? DEFAULT_ROLE_LIMITS.BUYER;
|
||||
|
||||
const key = `rate_limit:user:${userId}`;
|
||||
|
||||
try {
|
||||
const client = this.redis.getClient();
|
||||
|
||||
// Atomic increment + conditional TTL set via Lua script.
|
||||
// Uses INCR + conditional EXPIRE to avoid race conditions.
|
||||
const result = await client.eval(
|
||||
`local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {current, ttl}`,
|
||||
1,
|
||||
key,
|
||||
windowSeconds,
|
||||
) as [number, number];
|
||||
|
||||
const current = result[0];
|
||||
const ttl = result[1];
|
||||
|
||||
const response = context.switchToHttp().getResponse();
|
||||
|
||||
// Always set rate limit headers for observability
|
||||
response.setHeader('X-RateLimit-Limit', limit);
|
||||
response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - current));
|
||||
response.setHeader('X-RateLimit-Reset', ttl > 0 ? ttl : windowSeconds);
|
||||
|
||||
if (current > limit) {
|
||||
const retryAfter = ttl > 0 ? ttl : windowSeconds;
|
||||
response.setHeader('Retry-After', retryAfter);
|
||||
|
||||
this.logger.warn(
|
||||
`User rate limit exceeded: userId=${userId}, role=${role}, ` +
|
||||
`current=${current}/${limit}, retryAfter=${retryAfter}s`,
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
retryAfter,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Re-throw 429 errors — they are intentional
|
||||
if (error instanceof HttpException && error.getStatus() === HttpStatus.TOO_MANY_REQUESTS) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Fail open on Redis errors — log and allow the request
|
||||
this.logger.warn(
|
||||
`User rate limit check failed (Redis error), allowing request: userId=${userId}, error=${
|
||||
error instanceof Error ? error.message : 'unknown'
|
||||
}`,
|
||||
'UserRateLimitGuard',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Cacheable, type CacheableOptions } from './decorators/cacheable.decorator';
|
||||
export { CircuitBreaker, CircuitOpenError, CircuitState, type CircuitBreakerOptions } from './circuit-breaker';
|
||||
export { PrismaService } from './prisma.service';
|
||||
export { RedisService } from './redis.service';
|
||||
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||
@@ -11,6 +12,14 @@ export { SanitizeInputMiddleware } from './middleware/sanitize-input.middleware'
|
||||
export { CsrfMiddleware } from './middleware/csrf.middleware';
|
||||
export { maskPii } from './pii-masker';
|
||||
export { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard';
|
||||
export {
|
||||
UserRateLimitGuard,
|
||||
DEFAULT_ROLE_LIMITS,
|
||||
DEFAULT_WINDOW_SECONDS,
|
||||
USER_RATE_LIMIT_KEY,
|
||||
type UserRateLimitOptions,
|
||||
} from './guards/user-rate-limit.guard';
|
||||
export { UserRateLimit } from './decorators/user-rate-limit.decorator';
|
||||
export { FileValidationPipe } from './pipes/file-validation.pipe';
|
||||
export type { FileValidationOptions, UploadedFile } from './pipes/file-validation.pipe';
|
||||
export { validateEnv, validateJwtSecret } from './env-validation';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
|
||||
const CORRELATION_ID_HEADER = 'x-correlation-id';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { ForbiddenException, Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
|
||||
const CSRF_COOKIE = 'XSRF-TOKEN';
|
||||
const CSRF_HEADER = 'x-csrf-token';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
import { type LoggerService } from '../logger.service';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* Thin wrapper around ioredis.
|
||||
*
|
||||
* Uses `lazyConnect: true` so the app starts even if Redis is unreachable.
|
||||
* All call-sites (CacheService, health checks) already handle failures
|
||||
* gracefully — if Redis is down the app serves data directly from the DB.
|
||||
*
|
||||
* `enableReadyCheck: false` prevents ioredis from throwing
|
||||
* "Redis is not ready" errors during transient outages, allowing
|
||||
* individual commands to fail with a standard connection error
|
||||
* that the CacheService catches.
|
||||
*
|
||||
* `maxRetriesPerRequest: 1` ensures commands fail fast (single retry)
|
||||
* instead of blocking the event loop with exponential backoff.
|
||||
* The CacheService already treats Redis errors as non-fatal.
|
||||
*
|
||||
* `retryStrategy` implements bounded reconnection: waits 1 s then 2 s
|
||||
* then 3 s up to 5 s max, ensuring the client keeps trying to reconnect
|
||||
* without flooding the server.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly client: Redis;
|
||||
@@ -11,6 +31,11 @@ export class RedisService implements OnModuleDestroy {
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||
lazyConnect: true,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy(times: number): number {
|
||||
return Math.min(times * 1000, 5000);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +47,14 @@ export class RedisService implements OnModuleDestroy {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick health probe — returns true when the Redis connection is
|
||||
* in a usable state (`ready` or `connect`).
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.client.status === 'ready' || this.client.status === 'connect';
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Global, type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { CacheService, CACHE_HIT_TOTAL, CACHE_MISS_TOTAL } from './infrastructure/cache.service';
|
||||
import { PrometheusModule, makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||
import {
|
||||
CacheService,
|
||||
CACHE_HIT_TOTAL,
|
||||
CACHE_MISS_TOTAL,
|
||||
CACHE_DEGRADATION_TOTAL,
|
||||
} from './infrastructure/cache.service';
|
||||
import { EventBusService } from './infrastructure/event-bus.service';
|
||||
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||
import { LoggerService } from './infrastructure/logger.service';
|
||||
@@ -15,7 +21,11 @@ import { RedisService } from './infrastructure/redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [EventEmitterModule.forRoot()],
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
EventEmitterModule.forRoot(),
|
||||
PrometheusModule.register({ path: '/metrics', defaultMetrics: { enabled: true } }),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
@@ -32,12 +42,17 @@ import { RedisService } from './infrastructure/redis.service';
|
||||
help: 'Total number of cache misses',
|
||||
labelNames: ['resource'],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: CACHE_DEGRADATION_TOTAL,
|
||||
help: 'Total number of cache degradation events',
|
||||
labelNames: ['resource', 'operation'],
|
||||
}),
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: GlobalExceptionFilter,
|
||||
},
|
||||
],
|
||||
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService],
|
||||
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, PrometheusModule],
|
||||
})
|
||||
export class SharedModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
|
||||
Reference in New Issue
Block a user