Compare commits

..

10 Commits

34 changed files with 728 additions and 42 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"
},
+1 -1
View File
@@ -7,6 +7,6 @@ export default defineConfig({
},
engine: 'classic',
datasource: {
url: 'postgresql://postgres:root@localhost:5432/zemenu?schema=public',
url: 'postgresql://zemenu_db_user:Bx5QvACdW9iMC7k6guIQHZUM60c0a6jZ@dpg-d4n9ekggjchc73buc9qg-a.oregon-postgres.render.com/zemenu_db',
},
});
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Restaurant" ADD COLUMN "banner" TEXT;
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "ContactMessage" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"subject" TEXT,
"message" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContactMessage_pkey" PRIMARY KEY ("id")
);
+11 -1
View File
@@ -5,7 +5,7 @@ generator client {
datasource db {
provider = "postgresql"
url = "postgresql://postgres:root@localhost:5432/zemenu?schema=public"
url = "postgresql://zemenu_db_user:Bx5QvACdW9iMC7k6guIQHZUM60c0a6jZ@dpg-d4n9ekggjchc73buc9qg-a.oregon-postgres.render.com/zemenu_db"
}
model User {
@@ -84,6 +84,7 @@ model Restaurant {
slug String @unique // used as /domain.com/slug
description String?
logo String? // image URL
banner String?
ownerId String
owner User @relation(fields: [ownerId], references: [id])
@@ -134,3 +135,12 @@ model File {
@@index([folder, originalName])
}
model ContactMessage {
id String @id @default(uuid())
name String
email String
subject String?
message String
createdAt DateTime @default(now())
}
+6
View File
@@ -7,6 +7,9 @@ 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';
import { ContactModule } from './contact/contact.module';
@Module({
imports: [
@@ -17,6 +20,9 @@ import { FileModule } from './file/file.module';
RestaurantModule,
ItemModule,
FileModule,
RedisModule,
CacheModule,
ContactModule,
],
})
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);
+12
View File
@@ -0,0 +1,12 @@
export const keys = {
restaurantBySlug: (slug: string) => `restaurant:slug:${slug}`,
restaurantById: (id: string) => `restaurant:id:${id}`,
restaurantCategories: (restaurantId: string) =>
`restaurant:${restaurantId}:categories`,
restaurantItems: (restaurantId: string) => `restaurant:${restaurantId}:items`,
userRestaurants: (ownerId: string) => `user:${ownerId}:restaurants`,
category: (categoryId: string) => `category:${categoryId}`,
categoryItems: (categoryId: string) => `category:${categoryId}:items`,
item: (itemId: string) => `item:${itemId}`,
user: (userId: string) => `user:${userId}`,
};
+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],
+62 -14
View File
@@ -4,16 +4,45 @@ 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 cacheKey = keys.category(categoryId);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const category = await this.prisma.category
.findFirstOrThrow({
where: { id: categoryId },
include: { items: true, restaurant: true },
})
.catch(() => {
throw new NotFoundException('Category not found');
});
await this.cache.set(cacheKey, category, 600);
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,35 +52,43 @@ export class CategoryService {
},
});
await this.cache.del(keys.restaurantBySlug(restaurant.slug));
await this.cache.del(keys.restaurantCategories(restaurant.id));
return CategoryDto.fromEntity(category);
}
async findAllForRestaurant(restaurantId: string, userId: string) {
await this.restaurantService.verifyOwnership(userId, restaurantId);
const cacheKey = keys.restaurantCategories(restaurantId);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const categories = await this.prisma.category.findMany({
where: { restaurantId },
include: { items: true },
});
return categories.map(CategoryDto.fromEntity);
const result = categories.map(CategoryDto.fromEntity);
await this.cache.set(cacheKey, result, 300);
return result;
}
async findOne(id: string) {
const category = await this.prisma.category.findUnique({
where: { id },
include: { items: true },
});
const cacheKey = keys.category(id);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
if (!category) throw new NotFoundException('Category not found');
const category = await this._getCategorie(id);
const result = CategoryDto.fromEntity(category);
return CategoryDto.fromEntity(category);
await this.cache.set(cacheKey, result, 600);
return result;
}
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 +96,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,
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ContactService } from './contact.service';
import { AllowAnonymous, AuthGuard } from '@thallesp/nestjs-better-auth';
import { CreateContactDto } from './dto/createContant.dto';
@Controller('contact')
@UseGuards(AuthGuard)
export class ContactController {
constructor(private readonly contactService: ContactService) {}
@Post()
@AllowAnonymous()
async create(@Body() data: CreateContactDto) {
return this.contactService.create(data);
}
@Get()
async findAll() {
return this.contactService.findAll();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ContactService } from './contact.service';
import { ContactController } from './contact.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [ContactService],
controllers: [ContactController],
})
export class ContactModule {}
+44
View File
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { emailService } from '../shared/services/email.service';
import { CreateContactDto } from './dto/createContant.dto';
@Injectable()
export class ContactService {
constructor(private prisma: PrismaService) {}
async create(data: CreateContactDto) {
const message = await this.prisma.contactMessage.create({
data: {
name: data.name,
email: data.email,
subject: data.subject,
message: data.message,
},
});
// Send email notification (fire and forget, or await if critical)
// We'll catch errors so it doesn't fail the request if email fails
try {
await emailService.sendContactMessageNotification({
name: message.name,
email: message.email,
subject: message.subject || undefined,
message: message.message,
});
} catch (error) {
console.error('Failed to send contact notification email:', error);
}
return message;
}
async findAll() {
return this.prisma.contactMessage.findMany({
orderBy: {
createdAt: 'desc',
},
});
}
}
+22
View File
@@ -0,0 +1,22 @@
import { IsString, IsEmail, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class CreateContactDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsOptional()
@MaxLength(20)
subject?: string;
@IsString()
@IsNotEmpty()
@MaxLength(1000)
message: string;
}
+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],
})
+68 -12
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,32 +32,58 @@ export class ItemService {
},
});
if (category.restaurant) {
await this.cache.del(keys.restaurantBySlug(category.restaurant.slug));
}
await this.cache.del(keys.restaurantCategories(category.restaurantId));
await this.cache.del(keys.restaurantItems(category.restaurantId));
await this.cache.del(keys.categoryItems(category.id));
await this.cache.del(keys.category(category.id));
return MenuItemDto.fromEntity(item);
}
async findAllByCategory(categoryId: string) {
const cacheKey = keys.categoryItems(categoryId);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const items = await this.prisma.menuItem.findMany({
where: { categoryId },
orderBy: { createdAt: 'desc' },
});
return items.map(MenuItemDto.fromEntity);
const result = items.map(MenuItemDto.fromEntity);
await this.cache.set(cacheKey, result, 300);
return result;
}
async findAllForRestaurant(restaurantId: string) {
const cacheKey = keys.restaurantItems(restaurantId);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const items = await this.prisma.menuItem.findMany({
where: { restaurantId },
orderBy: { createdAt: 'desc' },
});
return items.map(MenuItemDto.fromEntity);
const result = items.map(MenuItemDto.fromEntity);
await this.cache.set(cacheKey, result, 300);
return result;
}
async findOne(id: string) {
const cacheKey = keys.item(id);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const item = await this.prisma.menuItem.findUnique({ where: { id } });
if (!item) throw new NotFoundException('Menu item not found');
return MenuItemDto.fromEntity(item);
const result = MenuItemDto.fromEntity(item);
await this.cache.set(cacheKey, result, 600);
return result;
}
async update(id: string, dto: UpdateItemDto, userId: string) {
@@ -62,6 +91,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 +102,46 @@ 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));
await this.cache.del(keys.restaurantItems(category.restaurantId));
await this.cache.del(keys.categoryItems(category.id));
await this.cache.del(keys.category(category.id));
await this.cache.del(keys.item(id));
// If category changed, invalidate old category caches too
if (dto.categoryId && dto.categoryId !== item.categoryId) {
await this.cache.del(keys.categoryItems(item.categoryId));
await this.cache.del(keys.category(item.categoryId));
}
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));
await this.cache.del(keys.restaurantItems(category.restaurantId));
await this.cache.del(keys.categoryItems(category.id));
await this.cache.del(keys.category(category.id));
await this.cache.del(keys.item(id));
return deleted;
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.enableCors({
origin: ['http://localhost:5173', 'http://localhost:5174'],
origin: ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000', 'http://localhost:3001'],
credentials: true,
});
+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;
@@ -17,6 +17,10 @@ export class CreateRestaurantDto {
@IsString()
logo?: string;
@IsOptional()
@IsString()
banner?: string;
@IsOptional()
template?: string;
}
+4
View File
@@ -6,6 +6,8 @@ export class RestaurantDto {
slug: string;
description?: string;
logo?: string;
banner?: string;
template?: string;
categories?: CategoryDto[];
createdAt?: Date;
@@ -16,6 +18,8 @@ export class RestaurantDto {
slug: entity.slug,
description: entity.description ?? undefined,
logo: entity.logo ?? undefined,
banner: entity.banner ?? undefined,
template: entity.template ?? undefined,
categories: entity.categories?.map(CategoryDto.fromEntity) ?? [],
createdAt: entity.createdAt ? new Date(entity.createdAt) : undefined,
};
+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],
+46 -4
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({
@@ -29,20 +34,29 @@ export class RestaurantService {
slug: dto.slug,
description: dto.description,
logo: dto.logo,
banner: dto.banner,
ownerId,
template: dto.template ?? null,
},
});
await this.cache.del(keys.userRestaurants(ownerId));
return RestaurantDto.fromEntity(restaurant);
}
async findByOwner(ownerId: string) {
const cacheKey = keys.userRestaurants(ownerId);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const restaurants = await this.prisma.restaurant.findMany({
where: { ownerId },
});
return restaurants.map(RestaurantDto.fromEntity);
const result = restaurants.map(RestaurantDto.fromEntity);
await this.cache.set(cacheKey, result, 300);
return result;
}
async findAll() {
@@ -51,6 +65,10 @@ export class RestaurantService {
}
async findOne(id: string) {
const cacheKey = keys.restaurantById(id);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const restaurant = await this.prisma.restaurant.findUnique({
where: { id },
});
@@ -59,7 +77,9 @@ export class RestaurantService {
throw new NotFoundException('Restaurant not found');
}
return RestaurantDto.fromEntity(restaurant);
const result = RestaurantDto.fromEntity(restaurant);
await this.cache.set(cacheKey, result, 600);
return result;
}
async findMyRestaurant(id: string, userId: string) {
@@ -68,6 +88,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: {
@@ -82,6 +106,8 @@ export class RestaurantService {
throw new NotFoundException('Restaurant not found');
}
await this.cache.set(cacheKey, restaurant);
return RestaurantDto.fromEntity(restaurant);
}
@@ -99,11 +125,27 @@ export class RestaurantService {
},
});
// Invalidate cache by slug and by id
await this.cache.del(keys.restaurantBySlug(updated.slug));
await this.cache.del(keys.restaurantById(updated.id));
await this.cache.del(keys.restaurantCategories(updated.id));
await this.cache.del(keys.restaurantItems(updated.id));
await this.cache.del(keys.userRestaurants(userId));
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.restaurantById(deleted.id));
await this.cache.del(keys.restaurantCategories(deleted.id));
await this.cache.del(keys.restaurantItems(deleted.id));
await this.cache.del(keys.userRestaurants(userId));
return deleted;
}
}
+3
View File
@@ -47,5 +47,8 @@ export const config = {
user: process.env.EMAIL_USER,
password: process.env.EMAIL_PASSWORD,
from: process.env.EMAIL_FROM || '"Zemenu" <no-reply@zemenu.com>',
contactNotificationEmails: process.env.CONTACT_NOTIFICATION_EMAILS
? process.env.CONTACT_NOTIFICATION_EMAILS.split(',').map(email => email.trim())
: [process.env.EMAIL_FROM || 'no-reply@zemenu.com'],
},
};
+15
View File
@@ -52,6 +52,21 @@ export class EmailService {
await this.sendEmail(to, 'Reset your password', html);
}
async sendContactMessageNotification(data: { name: string; email: string; subject?: string; message: string }) {
const content = `
<p>You have received a new contact message from <strong>${data.name}</strong> (${data.email}).</p>
<p><strong>Subject:</strong> ${data.subject || 'No Subject'}</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;">
<p style="white-space: pre-wrap;">${data.message}</p>
`;
const html = this.getHtmlTemplate('New Contact Message', content);
// Send to the configured notification emails
const recipients = config.email.contactNotificationEmails;
await this.sendEmail(recipients.join(','), `New Contact Message: ${data.subject || 'No Subject'}`, html);
}
private getHtmlTemplate(title: string, content: string): string {
const logoUrl = 'https://res.cloudinary.com/dmxoohiwo/image/upload/v1764315105/logo_fpczsd.png';
const currentYear = new Date().getFullYear();
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { CacheModule } from 'src/cache/cache.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, CacheModule],
controllers: [UserController],
providers: [UserService],
})
+20 -2
View File
@@ -1,9 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CacheService } from 'src/cache/cache.service';
import { keys } from 'src/cache/cache.keys';
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly cache: CacheService,
) {}
async getAllUsers() {
return this.prisma.user.findMany({
@@ -12,11 +17,17 @@ export class UserService {
}
async getUserById(id: string) {
const cacheKey = keys.user(id);
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const user = await this.prisma.user.findUnique({
where: { id },
include: { accounts: true, sessions: true, restaurants: true },
});
if (!user) throw new NotFoundException('User not found');
await this.cache.set(cacheKey, user, 600);
return user;
}
@@ -25,7 +36,14 @@ export class UserService {
await this.prisma.session.deleteMany({ where: { userId: id } });
await this.prisma.account.deleteMany({ where: { userId: id } });
await this.prisma.restaurant.deleteMany({ where: { ownerId: id } });
return this.prisma.user.delete({ where: { id } });
const deleted = await this.prisma.user.delete({ where: { id } });
// Invalidate caches
await this.cache.del(keys.user(id));
await this.cache.del(keys.userRestaurants(id));
return deleted;
}
async getSessionsByUserId(id: string) {