Files
goodgo-platform/apps/api/src/modules/shared/infrastructure/cache.service.ts
Ho Ngoc Hai 0c26dd85ef fix: resolve all lint errors across codebase
- Convert CacheTTL enum to const object to fix duplicate value errors
- Fix import ordering in test files (eslint-disable for vi.mock pattern)
- Fix unused variable warnings (prefix with underscore)
- Auto-fix import ordering in subscription page, dashboard layout
- 0 lint errors remaining

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 23:13:35 +07:00

116 lines
3.8 KiB
TypeScript

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';
export const CACHE_HIT_TOTAL = 'cache_hit_total';
export const CACHE_MISS_TOTAL = 'cache_miss_total';
export const CacheTTL = {
/** Listing detail — moderate TTL, invalidated on mutation */
LISTING_DETAIL: 300, // 5 min
/** Search results — short TTL due to high variability */
SEARCH_RESULTS: 60, // 1 min
/** District stats — moderate TTL, invalidated on listing events */
DISTRICT_STATS: 300, // 5 min
/** Market report — moderate TTL, invalidated on listing events */
MARKET_REPORT: 900, // 15 min
/** Heatmap data — moderate TTL, invalidated on listing events */
HEATMAP: 300, // 5 min
/** Price trend — long TTL, historical data changes infrequently */
MARKET_DATA: 1800, // 30 min
/** User profile — moderate TTL, invalidated on mutation */
USER_PROFILE: 600, // 10 min
} as const;
export enum CachePrefix {
LISTING = 'cache:listing',
SEARCH = 'cache:search',
GEO_SEARCH = 'cache:geo_search',
MARKET_REPORT = 'cache:market:report',
MARKET_TREND = 'cache:market:trend',
MARKET_HEATMAP = 'cache:market:heatmap',
MARKET_DISTRICT = 'cache:market:district',
USER_PROFILE = 'cache:user:profile',
}
@Injectable()
export class CacheService implements OnModuleInit {
constructor(
private readonly redis: RedisService,
private readonly logger: LoggerService,
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
) {}
onModuleInit(): void {
this.logger.log('CacheService initialized', 'CacheService');
}
/**
* Cache-aside: get from cache, or execute loader and store result.
*/
async getOrSet<T>(
key: string,
loader: () => Promise<T>,
ttlSeconds: number,
resource: string,
): Promise<T> {
try {
const cached = await this.redis.get(key);
if (cached !== null) {
this.cacheHitCounter.inc({ resource });
return JSON.parse(cached) as T;
}
} catch (err) {
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
}
this.cacheMissCounter.inc({ resource });
const result = await loader();
try {
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
} catch (err) {
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
}
return result;
}
/** Invalidate a single cache key. */
async invalidate(key: string): Promise<void> {
try {
await this.redis.del(key);
} catch (err) {
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
}
}
/** Invalidate all keys matching a prefix using SCAN (non-blocking). */
async invalidateByPrefix(prefix: string): Promise<void> {
try {
const client = this.redis.getClient();
let cursor = '0';
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', `${prefix}:*`, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
await client.del(...keys);
}
} while (cursor !== '0');
} catch (err) {
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
}
}
/** Build a deterministic cache key from prefix + parts. */
static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string {
const sanitized = parts
.filter((p) => p !== undefined)
.map((p) => String(p).toLowerCase().replace(/\s+/g, '_'));
return `${prefix}:${sanitized.join(':')}`;
}
}