diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4f08f1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + redis: + image: redis:latest + restart: always + ports: + - "6379:6379" + command: ["redis-server", "--appendonly", "yes"] diff --git a/package-lock.json b/package-lock.json index 12c5d14..17101ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@prisma/client": "^6.18.0", @@ -18,9 +19,11 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cloudinary": "^2.8.0", + "ioredis": "^5.8.2", "jose": "3.16.0", "multer": "^2.0.2", "nodemailer": "^7.0.11", + "redis": "^5.10.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -2180,6 +2183,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -3174,6 +3183,33 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz", + "integrity": "sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==", + "license": "MIT", + "dependencies": { + "dotenv": "16.4.7", + "dotenv-expand": "12.0.1", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "11.1.8", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.8.tgz", @@ -3776,6 +3812,66 @@ "@prisma/debug": "6.19.0" } }, + "node_modules/@redis/bloom": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz", + "integrity": "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/client": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", + "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/json": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.10.0.tgz", + "integrity": "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/search": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.10.0.tgz", + "integrity": "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.10.0.tgz", + "integrity": "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, "node_modules/@simplewebauthn/browser": { "version": "13.2.2", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", @@ -6565,6 +6661,15 @@ "node": ">=9" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6912,6 +7017,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6963,7 +7077,6 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -6972,6 +7085,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz", + "integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", @@ -8445,6 +8573,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9663,6 +9815,18 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -10857,6 +11021,43 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz", + "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.10.0", + "@redis/client": "5.10.0", + "@redis/json": "5.10.0", + "@redis/search": "5.10.0", + "@redis/time-series": "5.10.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -11297,6 +11498,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index b986611..efed169 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@prisma/client": "^6.18.0", @@ -29,9 +30,11 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cloudinary": "^2.8.0", + "ioredis": "^5.8.2", "jose": "3.16.0", "multer": "^2.0.2", "nodemailer": "^7.0.11", + "redis": "^5.10.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/src/app.module.ts b/src/app.module.ts index 6f473ba..f66e4cd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,8 @@ import { CategoryModule } from './category/category.module'; import { RestaurantModule } from './restaurant/restaurant.module'; import { ItemModule } from './item/item.module'; import { FileModule } from './file/file.module'; +import { RedisModule } from './redis/redis.module'; +import { CacheModule } from './cache/cache.module'; @Module({ imports: [ @@ -17,6 +19,8 @@ import { FileModule } from './file/file.module'; RestaurantModule, ItemModule, FileModule, + RedisModule, + CacheModule, ], }) export class AppModule {} diff --git a/src/cache/cache.helpers.ts b/src/cache/cache.helpers.ts new file mode 100644 index 0000000..309507c --- /dev/null +++ b/src/cache/cache.helpers.ts @@ -0,0 +1,2 @@ +export const serialize = (v: any) => JSON.stringify(v); +export const deserialize = (s: string | null) => (s ? JSON.parse(s) : null); diff --git a/src/cache/cache.keys.ts b/src/cache/cache.keys.ts new file mode 100644 index 0000000..91d246b --- /dev/null +++ b/src/cache/cache.keys.ts @@ -0,0 +1,7 @@ +export const keys = { + restaurantBySlug: (slug: string) => `restaurant:slug:${slug}`, + restaurantCategories: (restaurantId: string) => `restaurant:${restaurantId}:categories`, + restaurantItems: (restaurantId: string) => `restaurant:${restaurantId}:items`, + category: (categoryId: string) => `category:${categoryId}`, + item: (itemId: string) => `item:${itemId}`, +}; diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts new file mode 100644 index 0000000..9944bd8 --- /dev/null +++ b/src/cache/cache.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { CacheService } from './cache.service'; +import { RedisModule } from '../redis/redis.module'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [RedisModule, ConfigModule], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts new file mode 100644 index 0000000..724d72d --- /dev/null +++ b/src/cache/cache.service.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { Redis } from 'ioredis'; +import { REDIS_CLIENT } from '../redis/redis.types'; +import { ConfigService } from '@nestjs/config'; +import { serialize, deserialize } from './cache.helpers'; + +@Injectable() +export class CacheService { + private readonly ttl: number; + private readonly enabled: boolean; + private readonly logger = new Logger(CacheService.name); + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis, + private readonly config: ConfigService, + ) { + this.ttl = Number(config.get('REDIS_DEFAULT_TTL_SECONDS') ?? 600); + this.enabled = config.get('REDIS_CACHE_ENABLED') ?? true; + } + + async get(key: string): Promise { + if (!this.enabled) return null; + try { + const raw = await this.redis.get(key); + return deserialize(raw); + } catch (err) { + this.logger.warn(`Redis GET failed for ${key}: ${err}`); + return null; + } + } + + async set(key: string, value: any, ttlSeconds?: number): Promise { + if (!this.enabled) return; + try { + const payload = serialize(value); + const ttl = ttlSeconds ?? this.ttl; + if (ttl > 0) { + await this.redis.set(key, payload, 'EX', Math.floor(ttl)); + } else { + await this.redis.set(key, payload); + } + } catch (err) { + this.logger.warn(`Redis SET failed for ${key}: ${err}`); + } + } + + async del(key: string): Promise { + if (!this.enabled) return; + try { + await this.redis.del(key); + } catch (err) { + this.logger.warn(`Redis DEL failed for ${key}: ${err}`); + } + } + + // optional: delete many keys by pattern (use with caution in prod) + async delByPattern(pattern: string): Promise { + if (!this.enabled) return; + try { + const stream = this.redis.scanStream({ + match: pattern, + count: 100, + }); + + for await (const keys of stream) { + if (keys.length) { + await this.redis.del(...keys); + } + } + } catch (err) { + this.logger.warn(`Redis DELBYPATTERN failed for ${pattern}: ${err}`); + } + } +} diff --git a/src/category/category.module.ts b/src/category/category.module.ts index 8b9ca8c..8164197 100644 --- a/src/category/category.module.ts +++ b/src/category/category.module.ts @@ -3,9 +3,10 @@ import { CategoryController } from './category.controller'; import { CategoryService } from './category.service'; import { RestaurantModule } from 'src/restaurant/restaurant.module'; import { PrismaModule } from 'src/prisma/prisma.module'; +import { CacheModule } from 'src/cache/cache.module'; @Module({ - imports: [RestaurantModule, PrismaModule], + imports: [RestaurantModule, PrismaModule, CacheModule], controllers: [CategoryController], providers: [CategoryService], exports: [CategoryService], diff --git a/src/category/category.service.ts b/src/category/category.service.ts index 3b0eb3d..c244032 100644 --- a/src/category/category.service.ts +++ b/src/category/category.service.ts @@ -4,16 +4,36 @@ import { RestaurantService } from 'src/restaurant/restaurant.service'; import { CreateCategoryDto } from './dto/create-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto'; import { CategoryDto } from './dto/category.dto'; +import { CacheService } from 'src/cache/cache.service'; +import { keys } from 'src/cache/cache.keys'; @Injectable() export class CategoryService { constructor( private prisma: PrismaService, private readonly restaurantService: RestaurantService, + private cache: CacheService, ) {} + async _getCategorie(categoryId: string) { + const category = await this.prisma.category.findFirstOrThrow({ + where: { id: categoryId }, + include: { items: true, restaurant: true }, + }).catch(() => { + throw new NotFoundException('Category not found'); + }); + + return category; + } + async create(dto: CreateCategoryDto, userId: string) { - await this.restaurantService.verifyOwnership(userId, dto.restaurantId); + const restaurant = await this.prisma.restaurant.findUniqueOrThrow({ + where: { id: dto.restaurantId }, + }).catch(() => { + throw new NotFoundException('Restaurant not found'); + }); + + await this.restaurantService.verifyOwnership(userId, restaurant.id); const category = await this.prisma.category.create({ data: { @@ -23,6 +43,9 @@ export class CategoryService { }, }); + await this.cache.del(keys.restaurantBySlug(restaurant.slug)); + await this.cache.del(keys.restaurantCategories(restaurant.id)); + return CategoryDto.fromEntity(category); } @@ -38,20 +61,13 @@ export class CategoryService { } async findOne(id: string) { - const category = await this.prisma.category.findUnique({ - where: { id }, - include: { items: true }, - }); - - if (!category) throw new NotFoundException('Category not found'); - - + const category = await this._getCategorie(id); return CategoryDto.fromEntity(category); } async update(id: string, dto: UpdateCategoryDto, userId: string) { - const category = await this.findOne(id); + const category = await this._getCategorie(id); await this.restaurantService.verifyOwnership(userId, category.restaurantId); @@ -59,18 +75,29 @@ export class CategoryService { where: { id }, data: { name: dto.name, - restaurantId: dto.restaurantId, + // restaurantId: dto.restaurantId, }, }); + if (category.restaurant) { + await this.cache.del(keys.restaurantBySlug(category.restaurant.slug)); + } + await this.cache.del(keys.restaurantCategories(category.restaurantId)); + return CategoryDto.fromEntity(updated); } async remove(id: string, userId: string) { - const category = await this.findOne(id); + const category = await this._getCategorie(id); await this.restaurantService.verifyOwnership(userId, category.restaurantId); + if (category.restaurant) { + await this.cache.del(keys.restaurantBySlug(category.restaurant.slug)); + } + + await this.cache.del(keys.restaurantCategories(category.restaurantId)); + return this.prisma.category.delete({ where: { id } }); } } diff --git a/src/category/dto/category.dto.ts b/src/category/dto/category.dto.ts index 50ce021..46a15cd 100644 --- a/src/category/dto/category.dto.ts +++ b/src/category/dto/category.dto.ts @@ -1,4 +1,5 @@ import { MenuItemDto } from "src/item/dto/menu-item.dto"; +import { RestaurantDto } from "src/restaurant/dto/restaurant.dto"; export class CategoryDto { id: string; @@ -6,6 +7,7 @@ export class CategoryDto { image: string; restaurantId: string; items?: MenuItemDto[]; + restaurant?: RestaurantDto; static fromEntity(entity: any): CategoryDto { return { @@ -14,6 +16,7 @@ export class CategoryDto { image: entity.image, restaurantId: entity.restaurantId, items: entity.items?.map(MenuItemDto.fromEntity) ?? [], + restaurant: entity.restaurant ? RestaurantDto.fromEntity(entity.restaurant) : undefined, }; } } diff --git a/src/item/dto/menu-item.dto.ts b/src/item/dto/menu-item.dto.ts index a6e61ec..19c0388 100644 --- a/src/item/dto/menu-item.dto.ts +++ b/src/item/dto/menu-item.dto.ts @@ -1,10 +1,13 @@ +import { CategoryDto } from 'src/category/dto/category.dto'; + export class MenuItemDto { id: string; name: string; description?: string; price: number; image?: string; - categoryId?: string; + categoryId: string; + category?: CategoryDto; restaurantId: string; createdAt?: Date; @@ -15,7 +18,10 @@ export class MenuItemDto { description: entity.description ?? undefined, price: entity.price, image: entity.image ?? undefined, - categoryId: entity.categoryId ?? undefined, + categoryId: entity.categoryId, + category: entity.category + ? CategoryDto.fromEntity(entity.category) + : undefined, restaurantId: entity.restaurantId, createdAt: entity.createdAt ? new Date(entity.createdAt) : undefined, }; diff --git a/src/item/item.module.ts b/src/item/item.module.ts index e87c43b..c17df23 100644 --- a/src/item/item.module.ts +++ b/src/item/item.module.ts @@ -4,9 +4,10 @@ import { ItemService } from './item.service'; import { RestaurantModule } from 'src/restaurant/restaurant.module'; import { PrismaModule } from 'src/prisma/prisma.module'; import { CategoryModule } from 'src/category/category.module'; +import { CacheModule } from 'src/cache/cache.module'; @Module({ - imports: [RestaurantModule, CategoryModule, PrismaModule], + imports: [RestaurantModule, CategoryModule, PrismaModule, CacheModule], controllers: [ItemController], providers: [ItemService], }) diff --git a/src/item/item.service.ts b/src/item/item.service.ts index 353a58b..ad631f2 100644 --- a/src/item/item.service.ts +++ b/src/item/item.service.ts @@ -5,6 +5,8 @@ import { CreateItemDto } from './dto/create-item.dto'; import { UpdateItemDto } from './dto/update-item.dto'; import { MenuItemDto } from './dto/menu-item.dto'; import { CategoryService } from 'src/category/category.service'; +import { CacheService } from 'src/cache/cache.service'; +import { keys } from 'src/cache/cache.keys'; @Injectable() export class ItemService { @@ -12,10 +14,11 @@ export class ItemService { private prisma: PrismaService, private readonly restaurantService: RestaurantService, private readonly categoryService: CategoryService, + private readonly cache: CacheService, ) {} async create(dto: CreateItemDto, userId: string) { - const category = await this.categoryService.findOne(dto.categoryId); + const category = await this.categoryService._getCategorie(dto.categoryId); await this.restaurantService.verifyOwnership(userId, category.restaurantId); const item = await this.prisma.menuItem.create({ @@ -29,6 +32,11 @@ export class ItemService { }, }); + if (category.restaurant) { + await this.cache.del(keys.restaurantBySlug(category.restaurant.slug)); + } + await this.cache.del(keys.restaurantCategories(category.restaurantId)); + return MenuItemDto.fromEntity(item); } @@ -62,6 +70,10 @@ export class ItemService { await this.restaurantService.verifyOwnership(userId, item.restaurantId); + const category = await this.categoryService._getCategorie( + dto.categoryId != null ? dto.categoryId : item.categoryId, + ); + const updated = await this.prisma.menuItem.update({ where: { id }, data: { @@ -69,23 +81,32 @@ export class ItemService { description: dto.description, price: dto.price, image: dto.image, - categoryId: dto.categoryId - ? (await this.categoryService.findOne(dto.categoryId)).id - : item.categoryId, + categoryId: category.id, }, }); + if (category.restaurant) { + await this.cache.del(keys.restaurantBySlug(category.restaurant.slug)); + } + await this.cache.del(keys.restaurantCategories(category.restaurantId)); + return MenuItemDto.fromEntity(updated); } async remove(id: string, userId: string) { const item = await this.findOne(id); - await this.restaurantService.verifyOwnership( - userId, - item.restaurantId, - ); + await this.restaurantService.verifyOwnership(userId, item.restaurantId); - return this.prisma.menuItem.delete({ where: { id } }); + const deleted = await this.prisma.menuItem.delete({ where: { id } }); + + const category = await this.categoryService._getCategorie(item.categoryId); + + if (category.restaurant) { + await this.cache.del(keys.restaurantBySlug(category.restaurant.slug)); + } + await this.cache.del(keys.restaurantCategories(category.restaurantId)); + + return deleted; } } diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts new file mode 100644 index 0000000..63a16ff --- /dev/null +++ b/src/redis/redis.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { RedisProvider } from './redis.provider'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [RedisProvider], + exports: [RedisProvider], +}) +export class RedisModule {} diff --git a/src/redis/redis.provider.ts b/src/redis/redis.provider.ts new file mode 100644 index 0000000..6fe0d50 --- /dev/null +++ b/src/redis/redis.provider.ts @@ -0,0 +1,26 @@ +import { Provider } from '@nestjs/common'; +import IORedis from 'ioredis'; +import { REDIS_CLIENT } from './redis.types'; +import { ConfigService } from '@nestjs/config'; + +export const RedisProvider = { + provide: REDIS_CLIENT, + useFactory: (config: ConfigService) => { + const url = config.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'; + const client = new IORedis(url, { + // optional production tuning: + lazyConnect: false, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, + }); + + client.on('error', (err) => { + // log error with your logger + // eslint-disable-next-line no-console + console.error('Redis error', err); + }); + + return client; + }, + inject: [ConfigService], +} as Provider; diff --git a/src/redis/redis.types.ts b/src/redis/redis.types.ts new file mode 100644 index 0000000..af8d79f --- /dev/null +++ b/src/redis/redis.types.ts @@ -0,0 +1,3 @@ +import type { Redis } from 'ioredis'; +export const REDIS_CLIENT = 'REDIS_CLIENT'; +export type RedisClient = Redis; diff --git a/src/restaurant/restaurant.module.ts b/src/restaurant/restaurant.module.ts index b022565..a936bd5 100644 --- a/src/restaurant/restaurant.module.ts +++ b/src/restaurant/restaurant.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { RestaurantService } from './restaurant.service'; import { RestaurantController } from './restaurant.controller'; import { PrismaModule } from 'src/prisma/prisma.module'; +import { CacheModule } from 'src/cache/cache.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, CacheModule], providers: [RestaurantService], controllers: [RestaurantController], exports: [RestaurantService], diff --git a/src/restaurant/restaurant.service.ts b/src/restaurant/restaurant.service.ts index 1f94cba..0efdbbb 100644 --- a/src/restaurant/restaurant.service.ts +++ b/src/restaurant/restaurant.service.ts @@ -7,10 +7,15 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { CreateRestaurantDto } from './dto/create-restaurant.dto'; import { UpdateRestaurantDto } from './dto/update-restaurant.dto'; import { RestaurantDto } from './dto/restaurant.dto'; +import { CacheService } from 'src/cache/cache.service'; +import { keys } from 'src/cache/cache.keys'; @Injectable() export class RestaurantService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private cache: CacheService, + ) {} async verifyOwnership(userId: string, restaurantId: string) { const owned = await this.prisma.restaurant.findFirst({ @@ -69,6 +74,10 @@ export class RestaurantService { } async findBySlug(slug: string) { + const cacheKey = keys.restaurantBySlug(slug); + const cached = await this.cache.get(cacheKey); + if (cached) return cached; + const restaurant = await this.prisma.restaurant.findUnique({ where: { slug }, include: { @@ -83,6 +92,8 @@ export class RestaurantService { throw new NotFoundException('Restaurant not found'); } + await this.cache.set(cacheKey, restaurant); + return RestaurantDto.fromEntity(restaurant); } @@ -100,11 +111,23 @@ export class RestaurantService { }, }); + // Invalidate cache by slug and by id + await this.cache.del(keys.restaurantBySlug(updated.slug)); + await this.cache.del(keys.restaurantCategories(updated.id)); + await this.cache.del(keys.restaurantItems(updated.id)); + return RestaurantDto.fromEntity(updated); } async remove(id: string, userId: string) { await this.verifyOwnership(userId, id); - return this.prisma.restaurant.delete({ where: { id } }); + const deleted = await this.prisma.restaurant.delete({ where: { id } }); + + // invalidate + await this.cache.del(keys.restaurantBySlug(deleted.slug)); + await this.cache.del(keys.restaurantCategories(deleted.id)); + await this.cache.del(keys.restaurantItems(deleted.id)); + + return deleted; } }