feat: Implement menu item and category management with Redis caching for restaurant data.

This commit is contained in:
2025-12-11 09:54:52 +03:00
parent 5dc5cd87a8
commit 1bc01337fc
19 changed files with 467 additions and 29 deletions
+7
View File
@@ -0,0 +1,7 @@
services:
redis:
image: redis:latest
restart: always
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
+208 -1
View File
@@ -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",
+3
View File
@@ -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"
},
+4
View File
@@ -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 {}
+2
View File
@@ -0,0 +1,2 @@
export const serialize = (v: any) => JSON.stringify(v);
export const deserialize = (s: string | null) => (s ? JSON.parse(s) : null);
+7
View File
@@ -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}`,
};
+11
View File
@@ -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 {}
+74
View File
@@ -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<number>('REDIS_DEFAULT_TTL_SECONDS') ?? 600);
this.enabled = config.get<boolean>('REDIS_CACHE_ENABLED') ?? true;
}
async get<T = any>(key: string): Promise<T | null> {
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<void> {
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<void> {
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<void> {
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}`);
}
}
}
+2 -1
View File
@@ -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],
+39 -12
View File
@@ -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 } });
}
}
+3
View File
@@ -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,
};
}
}
+8 -2
View File
@@ -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,
};
+2 -1
View File
@@ -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],
})
+30 -9
View File
@@ -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;
}
}
+11
View File
@@ -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 {}
+26
View File
@@ -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<string>('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;
+3
View File
@@ -0,0 +1,3 @@
import type { Redis } from 'ioredis';
export const REDIS_CLIENT = 'REDIS_CLIENT';
export type RedisClient = Redis;
+2 -1
View File
@@ -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],
+25 -2
View File
@@ -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;
}
}