From 7995ae2948e6f264681482cf32203134ad012f6f Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Thu, 4 Jun 2026 17:04:28 +0330 Subject: [PATCH] add cache interceptor for hot endpoints --- src/common/constants/cache-keys.constant.ts | 14 +++++ .../decorators/cache-response.decorator.ts | 20 ++++++ .../interceptors/http-cache.interceptor.ts | 63 +++++++++++++++++++ .../foods/controllers/category.controller.ts | 3 + .../foods/controllers/food.controller.ts | 3 + .../foods/providers/category.service.ts | 13 ++++ src/modules/foods/providers/food.service.ts | 18 ++++-- .../notifications/processors/sms.processor.ts | 4 +- .../orders/listeners/order.listeners.ts | 4 +- .../pager/listeners/notification.listeners.ts | 4 +- .../payments/listeners/payment.listeners.ts | 4 +- .../controllers/restaurants.controller.ts | 3 + .../providers/restaurants.service.ts | 18 ++++++ src/modules/restaurants/restaurants.module.ts | 3 +- .../review/listeners/review.listeners.ts | 4 +- src/modules/utils/utils.module.ts | 6 +- 16 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 src/common/constants/cache-keys.constant.ts create mode 100644 src/common/decorators/cache-response.decorator.ts create mode 100644 src/core/interceptors/http-cache.interceptor.ts diff --git a/src/common/constants/cache-keys.constant.ts b/src/common/constants/cache-keys.constant.ts new file mode 100644 index 0000000..c1aa616 --- /dev/null +++ b/src/common/constants/cache-keys.constant.ts @@ -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; diff --git a/src/common/decorators/cache-response.decorator.ts b/src/common/decorators/cache-response.decorator.ts new file mode 100644 index 0000000..ff2e7d9 --- /dev/null +++ b/src/common/decorators/cache-response.decorator.ts @@ -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), + ); diff --git a/src/core/interceptors/http-cache.interceptor.ts b/src/core/interceptors/http-cache.interceptor.ts new file mode 100644 index 0000000..db14169 --- /dev/null +++ b/src/core/interceptors/http-cache.interceptor.ts @@ -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 { + const options = this.reflector.get( + CACHE_RESPONSE_KEY, + context.getHandler(), + ); + if (!options) { + return next.handle(); + } + + const request = context.switchToHttp().getRequest<{ params: Record }>(); + const key = this.buildCacheKey(options, request.params); + const ttl = + options.ttl ?? + this.configService.get('PUBLIC_CACHE_TTL', PUBLIC_CACHE_TTL); + + return from(this.cacheService.get(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 { + const paramValues = options.params?.length + ? options.params.map(name => params[name]).filter(Boolean) + : Object.values(params); + + return [options.keyPrefix, ...paramValues].join(':'); + } +} diff --git a/src/modules/foods/controllers/category.controller.ts b/src/modules/foods/controllers/category.controller.ts index 4da84aa..359c4c3 100644 --- a/src/modules/foods/controllers/category.controller.ts +++ b/src/modules/foods/controllers/category.controller.ts @@ -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' }) diff --git a/src/modules/foods/controllers/food.controller.ts b/src/modules/foods/controllers/food.controller.ts index 6f8d098..2e38c6b 100644 --- a/src/modules/foods/controllers/food.controller.ts +++ b/src/modules/foods/controllers/food.controller.ts @@ -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' }) diff --git a/src/modules/foods/providers/category.service.ts b/src/modules/foods/providers/category.service.ts index 0e0d809..0bbadea 100644 --- a/src/modules/foods/providers/category.service.ts +++ b/src/modules/foods/providers/category.service.ts @@ -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 { @@ -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 { + if (!slug) return; + await this.cacheService.del(CacheKeys.categoriesByRestaurant(slug)); } } diff --git a/src/modules/foods/providers/food.service.ts b/src/modules/foods/providers/food.service.ts index bd672ce..c08e594 100644 --- a/src/modules/foods/providers/food.service.ts +++ b/src/modules/foods/providers/food.service.ts @@ -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 { + if (!slug) return; + await this.cacheService.del(CacheKeys.foodsByRestaurant(slug)); } diff --git a/src/modules/notifications/processors/sms.processor.ts b/src/modules/notifications/processors/sms.processor.ts index 1fa3141..0462e7b 100644 --- a/src/modules/notifications/processors/sms.processor.ts +++ b/src/modules/notifications/processors/sms.processor.ts @@ -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(); } diff --git a/src/modules/orders/listeners/order.listeners.ts b/src/modules/orders/listeners/order.listeners.ts index 0f572d0..cd27abe 100644 --- a/src/modules/orders/listeners/order.listeners.ts +++ b/src/modules/orders/listeners/order.listeners.ts @@ -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('SMS_PATTERN_ORDER_CREATED') ?? '123'; this.orderStatusChangedSmsTemplateId = this.configService.get('SMS_PATTERN_ORDER_STATUS_CHANGE') ?? '123'; diff --git a/src/modules/pager/listeners/notification.listeners.ts b/src/modules/pager/listeners/notification.listeners.ts index fee7619..a473256 100644 --- a/src/modules/pager/listeners/notification.listeners.ts +++ b/src/modules/pager/listeners/notification.listeners.ts @@ -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('SMS_PATTERN_PAGER_CREATED') ?? '123'; } diff --git a/src/modules/payments/listeners/payment.listeners.ts b/src/modules/payments/listeners/payment.listeners.ts index 84ee0c5..a040ce4 100644 --- a/src/modules/payments/listeners/payment.listeners.ts +++ b/src/modules/payments/listeners/payment.listeners.ts @@ -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('SMS_PATTERN_ORDER_CREATED') ?? '123'; this.paymentSucceedSmsTemplateId = this.configService.get('SMS_PATTERN_ONLINE_PAYMENT_SUCCEED') ?? '123'; diff --git a/src/modules/restaurants/controllers/restaurants.controller.ts b/src/modules/restaurants/controllers/restaurants.controller.ts index 7a51620..ad223a4 100644 --- a/src/modules/restaurants/controllers/restaurants.controller.ts +++ b/src/modules/restaurants/controllers/restaurants.controller.ts @@ -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' }) diff --git a/src/modules/restaurants/providers/restaurants.service.ts b/src/modules/restaurants/providers/restaurants.service.ts index a579055..b44dc04 100644 --- a/src/modules/restaurants/providers/restaurants.service.ts +++ b/src/modules/restaurants/providers/restaurants.service.ts @@ -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 { @@ -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 { + 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 }); diff --git a/src/modules/restaurants/restaurants.module.ts b/src/modules/restaurants/restaurants.module.ts index 843e387..579937a 100644 --- a/src/modules/restaurants/restaurants.module.ts +++ b/src/modules/restaurants/restaurants.module.ts @@ -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 { } diff --git a/src/modules/review/listeners/review.listeners.ts b/src/modules/review/listeners/review.listeners.ts index 5250e18..99f374f 100644 --- a/src/modules/review/listeners/review.listeners.ts +++ b/src/modules/review/listeners/review.listeners.ts @@ -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('SMS_PATTERN_REVIEW_CREATED') ?? '123'; } diff --git a/src/modules/utils/utils.module.ts b/src/modules/utils/utils.module.ts index 2f7cef5..bead91a 100644 --- a/src/modules/utils/utils.module.ts +++ b/src/modules/utils/utils.module.ts @@ -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 {}