add cache interceptor for hot endpoints
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-06-04 17:04:28 +03:30
parent 0d619f313d
commit 7995ae2948
16 changed files with 165 additions and 19 deletions
@@ -0,0 +1,14 @@
/** Default TTL (seconds) for public read endpoints */
export const PUBLIC_CACHE_TTL = 300;
export const CacheKeyPrefixes = {
FOODS_BY_RESTAURANT: 'public:foods:restaurant',
CATEGORIES_BY_RESTAURANT: 'public:categories:restaurant',
RESTAURANT_SPEC: 'public:restaurants',
} as const;
export const CacheKeys = {
foodsByRestaurant: (slug: string) => `${CacheKeyPrefixes.FOODS_BY_RESTAURANT}:${slug}`,
categoriesByRestaurant: (slug: string) => `${CacheKeyPrefixes.CATEGORIES_BY_RESTAURANT}:${slug}`,
restaurantSpec: (slug: string) => `${CacheKeyPrefixes.RESTAURANT_SPEC}:${slug}`,
} as const;
@@ -0,0 +1,20 @@
import { SetMetadata, UseInterceptors, applyDecorators } from '@nestjs/common';
import { HttpCacheInterceptor } from 'src/core/interceptors/http-cache.interceptor';
import { PUBLIC_CACHE_TTL } from '../constants/cache-keys.constant';
export const CACHE_RESPONSE_KEY = 'cache_response';
export interface CacheResponseOptions {
/** Cache key prefix; route params are appended (e.g. `public:foods:restaurant` + `:slug`) */
keyPrefix: string;
/** TTL in seconds */
ttl?: number;
/** Route param names to include in the cache key (defaults to all route params) */
params?: string[];
}
export const CacheResponse = (options: CacheResponseOptions) =>
applyDecorators(
SetMetadata(CACHE_RESPONSE_KEY, { ttl: PUBLIC_CACHE_TTL, ...options }),
UseInterceptors(HttpCacheInterceptor),
);
@@ -0,0 +1,63 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Observable, from, of } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import {
CACHE_RESPONSE_KEY,
CacheResponseOptions,
} from 'src/common/decorators/cache-response.decorator';
import { PUBLIC_CACHE_TTL } from 'src/common/constants/cache-keys.constant';
import { CacheService } from 'src/modules/utils/cache.service';
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(
private readonly cacheService: CacheService,
private readonly reflector: Reflector,
private readonly configService: ConfigService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const options = this.reflector.get<CacheResponseOptions>(
CACHE_RESPONSE_KEY,
context.getHandler(),
);
if (!options) {
return next.handle();
}
const request = context.switchToHttp().getRequest<{ params: Record<string, string> }>();
const key = this.buildCacheKey(options, request.params);
const ttl =
options.ttl ??
this.configService.get<number>('PUBLIC_CACHE_TTL', PUBLIC_CACHE_TTL);
return from(this.cacheService.get<unknown>(key)).pipe(
mergeMap(cached => {
if (cached !== undefined && cached !== null) {
return of(cached);
}
return next.handle().pipe(
mergeMap(async data => {
const serialized = JSON.parse(JSON.stringify(data));
await this.cacheService.set(key, serialized, ttl);
return data;
}),
);
}),
);
}
private buildCacheKey(
options: CacheResponseOptions,
params: Record<string, string>,
): string {
const paramValues = options.params?.length
? options.params.map(name => params[name]).filter(Boolean)
: Object.values(params);
return [options.keyPrefix, ...paramValues].join(':');
}
}
@@ -19,6 +19,8 @@ import { RestId } from 'src/common/decorators';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { API_HEADER_SLUG } from 'src/common/constants';
import { CacheResponse } from 'src/common/decorators/cache-response.decorator';
import { CacheKeyPrefixes } from 'src/common/constants/cache-keys.constant';
@ApiTags('category')
@Controller()
@@ -26,6 +28,7 @@ export class CategoryController {
constructor(private readonly categoryService: CategoryService) { }
@Get('public/categories/restaurant/:slug')
@CacheResponse({ keyPrefix: CacheKeyPrefixes.CATEGORIES_BY_RESTAURANT, params: ['slug'] })
@ApiOperation({ summary: 'Get all categories by restaurant slug' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'slug', required: true, description: 'Restaurant Slug' })
@@ -19,6 +19,8 @@ import { OptionalAuthGuard } from 'src/modules/auth/guards/optinalAuth.guard';
import { API_HEADER_SLUG } from 'src/common/constants';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { CacheResponse } from 'src/common/decorators/cache-response.decorator';
import { CacheKeyPrefixes } from 'src/common/constants/cache-keys.constant';
@ApiTags('foods')
@Controller()
@@ -26,6 +28,7 @@ export class FoodController {
constructor(private readonly foodsService: FoodService) { }
@Get('public/foods/restaurant/:slug')
@CacheResponse({ keyPrefix: CacheKeyPrefixes.FOODS_BY_RESTAURANT, params: ['slug'] })
@ApiOperation({ summary: 'Get all foods by restaurant slug' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'slug', required: true, description: 'Restaurant Slug' })
@@ -8,6 +8,8 @@ import { Category } from '../entities/category.entity';
import { CategoryMessage, RestMessage } from 'src/common/enums/message.enum';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service';
import { CacheService } from 'src/modules/utils/cache.service';
import { CacheKeys } from 'src/common/constants/cache-keys.constant';
@Injectable()
export class CategoryService {
@@ -15,6 +17,7 @@ export class CategoryService {
private readonly categoryRepository: CategoryRepository,
private readonly restaurantsService: RestaurantsService,
private readonly em: EntityManager,
private readonly cacheService: CacheService,
) { }
async create(restId: string, dto: CreateCategoryDto): Promise<Category> {
@@ -31,6 +34,7 @@ export class CategoryService {
const category = this.categoryRepository.create(data);
await this.em.persistAndFlush(category);
await this.invalidateCategoriesCache(restaurant.slug);
return category;
}
@@ -58,6 +62,8 @@ export class CategoryService {
const cat = await this.findOneOrFail(restId, id)
this.em.assign(cat, dto);
await this.em.persistAndFlush(cat);
const restaurant = await this.restaurantsService.findOneOrFail(restId);
await this.invalidateCategoriesCache(restaurant.slug);
return cat;
}
@@ -65,5 +71,12 @@ export class CategoryService {
const cat = await this.findOneOrFail(restId, id)
cat.deletedAt = new Date();
await this.em.persistAndFlush(cat);
const restaurant = await this.restaurantsService.findOneOrFail(restId);
await this.invalidateCategoriesCache(restaurant.slug);
}
private async invalidateCategoriesCache(slug?: string): Promise<void> {
if (!slug) return;
await this.cacheService.del(CacheKeys.categoriesByRestaurant(slug));
}
}
+13 -5
View File
@@ -12,6 +12,8 @@ import { MealType } from '../interface/food.interface';
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { CacheService } from 'src/modules/utils/cache.service';
import { CacheKeys } from 'src/common/constants/cache-keys.constant';
@Injectable()
export class FoodService {
@@ -20,6 +22,7 @@ export class FoodService {
private readonly categoryRepository: CategoryRepository,
private readonly restService: RestaurantsService,
private readonly em: EntityManager,
private readonly cacheService: CacheService,
) { }
async create(restId: string, createFoodDto: CreateFoodDto) {
@@ -72,6 +75,8 @@ export class FoodService {
: null;
const savedInventory = inventory?.id ? await this.em.findOne(Inventory, { id: inventory.id }) : inventory;
await this.invalidateRestaurantFoodsCache(restaurant.slug);
return { food: savedFood ?? food, inventory: savedInventory ?? inventory };
}
@@ -233,8 +238,7 @@ export class FoodService {
// Re-load the food to ensure populated relations and stable instance
const savedFood = await this.foodRepository.findOne({ id: food.id }, { populate: ['category', 'restaurant'] });
// Invalidate cache for the restaurant if present (optional)
// await this.invalidateRestaurantFoodsCache(savedFood?.restaurant?.slug);
await this.invalidateRestaurantFoodsCache(savedFood?.restaurant?.slug ?? food.restaurant?.slug);
return savedFood ?? food;
}
@@ -245,12 +249,16 @@ export class FoodService {
throw new NotFoundException(FoodMessage.NOT_FOUND);
}
// const restaurantSlug = food.restaurant.slug;
const restaurantSlug = food.restaurant.slug;
food.deletedAt = new Date();
await this.em.persistAndFlush(food);
// Invalidate cache for the restaurant
// await this.invalidateRestaurantFoodsCache(restaurantSlug);
await this.invalidateRestaurantFoodsCache(restaurantSlug);
}
private async invalidateRestaurantFoodsCache(slug?: string): Promise<void> {
if (!slug) return;
await this.cacheService.del(CacheKeys.foodsByRestaurant(slug));
}
@@ -2,7 +2,7 @@ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { CreateRequestContext } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/postgresql';
import { EntityManager } from '@mikro-orm/postgresql';
import { UserRepository } from 'src/modules/users/repositories/user.repository';
import { SmsNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
@@ -20,7 +20,7 @@ export class SmsProcessor extends WorkerHost {
private readonly userRepository: UserRepository,
private readonly smsService: SmsService,
private readonly eventEmitter: EventEmitter2,
private readonly orm: MikroORM,
private readonly em: EntityManager,
) {
super();
}
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EnsureRequestContext } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/postgresql';
import { EntityManager } from '@mikro-orm/postgresql';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
import { Permission } from 'src/common/enums/permission.enum';
@@ -26,7 +26,7 @@ export class OrderListeners {
private readonly notificationService: NotificationService,
private readonly configService: ConfigService,
private readonly userService: UserService,
private readonly orm: MikroORM,
private readonly em: EntityManager,
) {
this.orderCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_CREATED') ?? '123';
this.orderStatusChangedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_STATUS_CHANGE') ?? '123';
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EnsureRequestContext } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/postgresql';
import { EntityManager } from '@mikro-orm/postgresql';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { PagerCreatedEvent } from '../events/pager.events';
import { Permission } from 'src/common/enums/permission.enum';
@@ -17,7 +17,7 @@ export class PagerListeners {
private readonly adminService: AdminRepository,
private readonly notificationService: NotificationService,
private readonly configService: ConfigService,
private readonly orm: MikroORM,
private readonly em: EntityManager,
) {
this.smsPatternPagerCreated = this.configService.get<string>('SMS_PATTERN_PAGER_CREATED') ?? '123';
}
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EnsureRequestContext } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/postgresql';
import { EntityManager } from '@mikro-orm/postgresql';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events';
import { Permission } from 'src/common/enums/permission.enum';
@@ -20,7 +20,7 @@ export class PaymentListeners {
private readonly notificationService: NotificationService,
private readonly configService: ConfigService,
private readonly orderService: OrdersService,
private readonly orm: MikroORM,
private readonly em: EntityManager,
) {
this.orderCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_CREATED') ?? '123';
this.paymentSucceedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ONLINE_PAYMENT_SUCCEED') ?? '123';
@@ -19,6 +19,8 @@ import { API_HEADER_SLUG } from 'src/common/constants';
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { CacheResponse } from 'src/common/decorators/cache-response.decorator';
import { CacheKeyPrefixes } from 'src/common/constants/cache-keys.constant';
@ApiTags('restaurants')
@Controller()
@@ -26,6 +28,7 @@ export class RestaurantsController {
constructor(private readonly restaurantsService: RestaurantsService) { }
@Get('public/restaurants/:slug')
@CacheResponse({ keyPrefix: CacheKeyPrefixes.RESTAURANT_SPEC, params: ['slug'] })
@ApiOperation({ summary: 'Get restaurant specification by slug' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'slug', required: true, description: 'Restaurant slug' })
@@ -27,6 +27,8 @@ import { Schedule } from '../entities/schedule.entity';
import { WalletTransaction } from '../../users/entities/wallet-transaction.entity';
import { SmsLog } from '../../notifications/entities/smsLogs.entity';
import { Notification } from '../../notifications/entities/notification.entity';
import { CacheService } from 'src/modules/utils/cache.service';
import { CacheKeys } from 'src/common/constants/cache-keys.constant';
@Injectable()
@@ -34,6 +36,7 @@ export class RestaurantsService {
constructor(
private readonly em: EntityManager,
private readonly restRepository: RestRepository,
private readonly cacheService: CacheService,
) { }
async setupRestuarant(dto: CreateRestaurantDto): Promise<Restaurant> {
@@ -184,13 +187,28 @@ export class RestaurantsService {
}
const previousSlug = restaurant.slug;
this.restRepository.assign(restaurant, dto);
await this.em.persistAndFlush(restaurant);
await this.invalidateRestaurantCache(previousSlug);
if (restaurant.slug !== previousSlug) {
await this.invalidateRestaurantCache(restaurant.slug);
}
return restaurant;
}
private async invalidateRestaurantCache(slug?: string): Promise<void> {
if (!slug) return;
await Promise.all([
this.cacheService.del(CacheKeys.restaurantSpec(slug)),
this.cacheService.del(CacheKeys.foodsByRestaurant(slug)),
this.cacheService.del(CacheKeys.categoriesByRestaurant(slug)),
]);
}
async remove(id: string) {
// Soft delete the restaurant by setting isActive to false
const restaurant = await this.restRepository.findOne({ id });
@@ -11,11 +11,12 @@ import { ScheduleController } from './controllers/schedule.controller';
import { RestaurantCrone } from './crone/restaurant.crone';
import { JwtModule } from '@nestjs/jwt';
import { AuthModule } from '../auth/auth.module';
import { UtilsModule } from '../utils/utils.module';
@Module({
controllers: [RestaurantsController, ScheduleController],
providers: [RestaurantsService, RestRepository, ScheduleRepository, ScheduleService, RestaurantCrone],
imports: [MikroOrmModule.forFeature([Restaurant, Schedule]), JwtModule, forwardRef(() => AuthModule)],
imports: [MikroOrmModule.forFeature([Restaurant, Schedule]), JwtModule, forwardRef(() => AuthModule), UtilsModule],
exports: [RestRepository, ScheduleRepository, ScheduleService, RestaurantsService],
})
export class RestaurantsModule { }
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EnsureRequestContext } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/postgresql';
import { EntityManager } from '@mikro-orm/postgresql';
import { ReviewCreatedEvent } from '../events/review.events';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { Permission } from 'src/common/enums/permission.enum';
@@ -18,7 +18,7 @@ export class ReviewListeners {
private readonly adminService: AdminRepository,
private readonly notificationService: NotificationService,
private readonly configService: ConfigService,
private readonly orm: MikroORM,
private readonly em: EntityManager,
) {
this.reviewCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_REVIEW_CREATED') ?? '123';
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { HttpCacheInterceptor } from 'src/core/interceptors/http-cache.interceptor';
import { CacheService } from './cache.service';
@Module({
imports: [],
controllers: [],
providers: [CacheService, ],
exports: [CacheService],
providers: [CacheService, HttpCacheInterceptor],
exports: [CacheService, HttpCacheInterceptor],
})
export class UtilsModule {}