feat: add class-transformer dependency for better data transformation

refactor(prisma): update User model to include banned status and ban details

refactor(prisma): modify Account model to include new authentication fields

refactor(prisma): enhance Session model with impersonation tracking

refactor(prisma): adjust Verification model to include value field

feat(auth): integrate admin plugin and enhance email verification process

refactor(category): enforce ownership checks in category service methods

refactor(item): update item service to validate category ownership

feat(item): add ability to fetch items by category

refactor(restaurant): implement ownership checks and admin routes in restaurant controller

fix(user): restrict user access to admin routes

feat(exception): implement global exception filter for consistent error handling
This commit is contained in:
2025-11-09 21:47:18 +03:00
parent f0948a5b05
commit 04b7c41abf
21 changed files with 678 additions and 173 deletions
+15 -2
View File
@@ -1,7 +1,7 @@
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
// If your Prisma file is located elsewhere, you can change the path
import { PrismaClient } from '@prisma/client';
import { admin } from 'better-auth/plugins';
const prisma = new PrismaClient();
@@ -9,12 +9,25 @@ export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
plugins: [admin()],
emailAndPassword: {
enabled: true,
// requireEmailVerification: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, token }) => {
console.log(`Send reset password email to ${user.email}: ${token}`);
},
},
emailVerification: {
sendVerificationEmail: async ({ user, token }) => {
console.log(`Send verification email to ${user.email}: ${token}`);
},
sendOnSignIn: true,
autoSignInAfterVerification: true,
},
trustedOrigins: ['http://localhost:5173', 'http://localhost:5174'],
socialProviders: {
google: {
prompt: 'select_account',
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
+9 -8
View File
@@ -8,17 +8,17 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import { AuthGuard, Session } from '@thallesp/nestjs-better-auth';
import type { UserSession } from '@thallesp/nestjs-better-auth';
import { CategoryService } from './category.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { AuthGuard, Session } from '@thallesp/nestjs-better-auth';
import type { UserSession } from '@thallesp/nestjs-better-auth';
@Controller('categories')
@UseGuards(AuthGuard)
export class CategoryController {
constructor(private readonly service: CategoryService) {}
@UseGuards(AuthGuard)
@Post()
async create(
@Body() dto: CreateCategoryDto,
@@ -28,8 +28,11 @@ export class CategoryController {
}
@Get('restaurant/:restaurantId')
async findAllForRestaurant(@Param('restaurantId') restaurantId: string) {
return await this.service.findAllForRestaurant(restaurantId);
async findAllForRestaurant(
@Param('restaurantId') restaurantId: string,
@Session() session: UserSession,
) {
return await this.service.findAllForRestaurant(restaurantId, session.user.id);
}
@Get(':id')
@@ -37,7 +40,6 @@ export class CategoryController {
return await this.service.findOne(id);
}
@UseGuards(AuthGuard)
@Put(':id')
async update(
@Param('id') id: string,
@@ -47,9 +49,8 @@ export class CategoryController {
return await this.service.update(id, dto, session.user.id);
}
@UseGuards(AuthGuard)
@Delete(':id')
async remove(@Param('id') id: string, @Session() session: UserSession) {
await this.service.remove(id, session.user.id);
return await this.service.remove(id, session.user.id);
}
}
+16 -12
View File
@@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RestaurantService } from 'src/restaurant/restaurant.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { RestaurantService } from 'src/restaurant/restaurant.service';
import { CategoryDto } from './dto/category.dto';
@Injectable()
@@ -14,19 +14,25 @@ export class CategoryService {
async create(dto: CreateCategoryDto, userId: string) {
await this.restaurantService.verifyOwnership(userId, dto.restaurantId);
return this.prisma.category.create({
const category = await this.prisma.category.create({
data: {
name: dto.name,
restaurantId: dto.restaurantId,
},
});
return CategoryDto.fromEntity(category);
}
async findAllForRestaurant(restaurantId: string) {
async findAllForRestaurant(restaurantId: string, userId: string) {
await this.restaurantService.verifyOwnership(userId, restaurantId);
const categories = await this.prisma.category.findMany({
where: { restaurantId },
include: { items: true },
});
return categories.map(CategoryDto.fromEntity);
}
@@ -35,19 +41,20 @@ export class CategoryService {
where: { id },
include: { items: true },
});
if (!category) throw new NotFoundException('Category not found');
return CategoryDto.fromEntity(category);
}
async update(id: string, dto: UpdateCategoryDto, userId: string) {
const category = await this.findOne(id);
if (!category) {
throw new NotFoundException('Category not found');
}
await this.restaurantService.verifyOwnership(userId, category.restaurantId);
const savedCategory = await this.prisma.category.update({
const updated = await this.prisma.category.update({
where: { id },
data: {
name: dto.name,
@@ -55,17 +62,14 @@ export class CategoryService {
},
});
return CategoryDto.fromEntity(savedCategory);
return CategoryDto.fromEntity(updated);
}
async remove(id: string, userId: string) {
const category = await this.findOne(id);
if (!category) {
throw new NotFoundException('Category not found');
}
await this.restaurantService.verifyOwnership(userId, category.restaurantId);
this.prisma.category.delete({ where: { id } });
return this.prisma.category.delete({ where: { id } });
}
}
+5 -1
View File
@@ -1,10 +1,14 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateCategoryDto {
@IsNotEmpty()
@IsString()
name: string;
@IsOptional()
@IsString()
image?: string;
@IsNotEmpty()
@IsUUID()
restaurantId: string;
@@ -0,0 +1,32 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
if (typeof res === 'string') message = res;
else if (typeof res === 'object') message = (res as any).message || res;
}
return response.status(status).json({
success: false,
status,
message,
});
}
}
+2 -6
View File
@@ -23,11 +23,7 @@ export class CreateItemDto {
@IsString()
image?: string;
@IsOptional()
@IsNotEmpty()
@IsUUID()
categoryId?: string;
@IsNotEmpty()
@IsUUID()
restaurantId: string;
categoryId: string;
}
+13 -13
View File
@@ -8,48 +8,48 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import { AuthGuard, Session } from '@thallesp/nestjs-better-auth';
import type { UserSession } from '@thallesp/nestjs-better-auth';
import { ItemService } from './item.service';
import { CreateItemDto } from './dto/create-item.dto';
import { UpdateItemDto } from './dto/update-item.dto';
import { MenuItemDto } from './dto/menu-item.dto';
import { AuthGuard, Session } from '@thallesp/nestjs-better-auth';
import type { UserSession } from '@thallesp/nestjs-better-auth';
@Controller('items')
@UseGuards(AuthGuard)
export class ItemController {
constructor(private readonly service: ItemService) {}
@UseGuards(AuthGuard)
@Post()
async create(@Body() dto: CreateItemDto, @Session() session: UserSession) {
const entity = await this.service.create(dto, session.user.id);
return MenuItemDto.fromEntity(entity);
return await this.service.create(dto, session.user.id);
}
@Get('/category/:categoryId')
async findAllByCategory(@Param('categoryId') categoryId: string) {
const items = await this.service.findAllByCategory(categoryId);
return items.map(MenuItemDto.fromEntity);
}
@Get('restaurant/:restaurantId')
async findAllForRestaurant(@Param('restaurantId') restaurantId: string) {
const items = await this.service.findAllForRestaurant(restaurantId);
return items.map(MenuItemDto.fromEntity);
return await this.service.findAllForRestaurant(restaurantId);
}
@Get(':id')
async findOne(@Param('id') id: string) {
const entity = await this.service.findOne(id);
return MenuItemDto.fromEntity(entity);
return await this.service.findOne(id);
}
@UseGuards(AuthGuard)
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateItemDto,
@Session() session: UserSession,
) {
const entity = await this.service.update(id, dto, session.user.id);
return MenuItemDto.fromEntity(entity);
return await this.service.update(id, dto, session.user.id);
}
@UseGuards(AuthGuard)
@Delete(':id')
async remove(@Param('id') id: string, @Session() session: UserSession) {
await this.service.remove(id, session.user.id);
+2 -1
View File
@@ -3,9 +3,10 @@ import { ItemController } from './item.controller';
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';
@Module({
imports: [RestaurantModule, PrismaModule],
imports: [RestaurantModule, CategoryModule, PrismaModule],
controllers: [ItemController],
providers: [ItemService],
})
+28 -19
View File
@@ -1,32 +1,45 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RestaurantService } from 'src/restaurant/restaurant.service';
import { CreateItemDto } from './dto/create-item.dto';
import { UpdateItemDto } from './dto/update-item.dto';
import { RestaurantService } from 'src/restaurant/restaurant.service';
import { MenuItemDto } from './dto/menu-item.dto';
import { CategoryService } from 'src/category/category.service';
@Injectable()
export class ItemService {
constructor(
private prisma: PrismaService,
private readonly restaurantService: RestaurantService,
private readonly categoryService: CategoryService,
) {}
async create(dto: CreateItemDto, userId: string) {
await this.restaurantService.verifyOwnership(userId, dto.restaurantId);
const category = await this.categoryService.findOne(dto.categoryId);
await this.restaurantService.verifyOwnership(userId, category.restaurantId);
const item = await this.prisma.menuItem.create({
data: {
name: dto.name,
description: dto.description,
price: dto.price,
image: dto.image,
categoryId: dto.categoryId ?? null,
restaurantId: dto.restaurantId,
categoryId: category.id,
restaurantId: category.restaurantId,
},
});
return MenuItemDto.fromEntity(item);
}
async findAllByCategory(categoryId: string) {
const items = await this.prisma.menuItem.findMany({
where: { categoryId },
orderBy: { createdAt: 'desc' },
});
return items.map(MenuItemDto.fromEntity);
}
async findAllForRestaurant(restaurantId: string) {
const items = await this.prisma.menuItem.findMany({
where: { restaurantId },
@@ -38,43 +51,39 @@ export class ItemService {
async findOne(id: string) {
const item = await this.prisma.menuItem.findUnique({ where: { id } });
if (!item) throw new NotFoundException('Menu item not found');
return MenuItemDto.fromEntity(item);
}
async update(id: string, dto: UpdateItemDto, userId: string) {
const item = await this.findOne(id);
if (!item) {
throw new NotFoundException('Menu item not found');
}
await this.restaurantService.verifyOwnership(userId, item.restaurantId);
const updatedItem = await this.prisma.menuItem.update({
const updated = await this.prisma.menuItem.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
price: dto.price,
image: dto.image,
categoryId: dto.categoryId ?? undefined,
restaurantId: dto.restaurantId ?? undefined,
categoryId: dto.categoryId
? (await this.categoryService.findOne(dto.categoryId)).id
: item.categoryId,
},
});
return MenuItemDto.fromEntity(updatedItem);
return MenuItemDto.fromEntity(updated);
}
async remove(id: string, userId: string) {
const item = await this.findOne(id);
if (!item) {
throw new NotFoundException('Menu item not found');
}
await this.restaurantService.verifyOwnership(userId, item.restaurantId);
return this.prisma.menuItem.delete({ where: { id } });
return await this.restaurantService.verifyOwnership(
userId,
item.restaurantId,
);
}
}
+35 -3
View File
@@ -1,10 +1,42 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthService } from '@thallesp/nestjs-better-auth';
import { toNodeHandler } from 'better-auth/node';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
bodyParser: false,
// Disable NestJS's built-in body parser so we can control ordering
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.enableCors({
origin: ['http://localhost:5173', 'http://localhost:5174'],
credentials: true,
});
await app.listen(process.env.PORT ?? 3000);
// Access Express instance
const expressApp = app.getHttpAdapter().getInstance();
// Access BetterAuth instance from AuthService
const authService = app.get<AuthService>(AuthService);
// Mount BetterAuth before body parsers
expressApp.all(
/^\/api\/auth\/.*/,
toNodeHandler(authService.instance.handler),
);
// Re-enable Nest's JSON body parser AFTER mounting BetterAuth
expressApp.use(require('express').json());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.setGlobalPrefix('api');
await app.listen(3000);
}
bootstrap();
+33 -24
View File
@@ -6,62 +6,71 @@ import {
Param,
Post,
Put,
Request,
UseGuards,
Session,
} from '@nestjs/common';
import { RestaurantService } from './restaurant.service';
import {
AllowAnonymous,
AuthGuard,
Roles,
Session as UserSession,
} from '@thallesp/nestjs-better-auth';
import type { UserSession as SessionType } from '@thallesp/nestjs-better-auth';
import { CreateRestaurantDto } from './dto/create-restaurant.dto';
import { UpdateRestaurantDto } from './dto/update-restaurant.dto';
import { AuthGuard, Session } from '@thallesp/nestjs-better-auth';
import type { UserSession } from '@thallesp/nestjs-better-auth';
import { RestaurantService } from './restaurant.service';
@Controller('restaurants')
@UseGuards(AuthGuard)
export class RestaurantController {
constructor(private readonly service: RestaurantService) {}
@UseGuards(AuthGuard)
@Post()
async create(
@Body() dto: CreateRestaurantDto,
@Session() session: UserSession,
@Session() session: SessionType,
) {
const ownerId = session.user.id;
return await this.service.create(ownerId, dto);
}
@UseGuards(AuthGuard)
@Get('my')
async myRestaurants(@Session() session) {
return await this.service.findByOwner(session.user.id);
return await this.service.create(session.user.id, dto);
}
@Get()
async findAll() {
return await this.service.findAll();
async findOwnerRestaurants(@Session() session: SessionType) {
return await this.service.findByOwner(session.user.id);
}
@Get(':id')
async findOne(@Param('id') id: string) {
return await this.service.findOne(id);
async findOne(@Param('id') id: string, @Session() session: SessionType) {
return await this.service.findMyRestaurant(id, session.user.id);
}
@UseGuards(AuthGuard)
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateRestaurantDto,
@Session() session: UserSession,
@Session() session: SessionType,
) {
return await this.service.update(id, dto, session.user.id);
}
@UseGuards(AuthGuard)
@Delete(':id')
async remove(@Param('id') id: string, @Session() session: UserSession) {
await this.service.remove(id, session.user.id);
async remove(@Param('id') id: string, @Session() session: SessionType) {
return await this.service.remove(id, session.user.id);
}
// public menu by slug
@Roles(['admin'])
@Get('/admin/all')
async adminFindAll() {
return await this.service.findAll();
}
@Roles(['admin'])
@Get('/admin/:id')
async adminFindOne(@Param('id') id: string) {
return await this.service.findOne(id);
}
@AllowAnonymous()
@Get('/public/:slug')
async publicBySlug(@Param('slug') slug: string) {
return await this.service.findBySlug(slug);
+20 -17
View File
@@ -3,9 +3,9 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateRestaurantDto } from './dto/create-restaurant.dto';
import { UpdateRestaurantDto } from './dto/update-restaurant.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import { RestaurantDto } from './dto/restaurant.dto';
@Injectable()
@@ -13,11 +13,11 @@ export class RestaurantService {
constructor(private prisma: PrismaService) {}
async verifyOwnership(userId: string, restaurantId: string) {
const restaurant = await this.prisma.restaurant.findFirst({
const owned = await this.prisma.restaurant.findFirst({
where: { id: restaurantId, ownerId: userId },
});
if (!restaurant) {
if (!owned) {
throw new ForbiddenException('You do not own this restaurant');
}
}
@@ -47,7 +47,6 @@ export class RestaurantService {
async findAll() {
const restaurants = await this.prisma.restaurant.findMany();
return restaurants.map(RestaurantDto.fromEntity);
}
@@ -55,11 +54,19 @@ export class RestaurantService {
const restaurant = await this.prisma.restaurant.findUnique({
where: { id },
});
if (!restaurant) throw new NotFoundException('Restaurant not found');
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
return RestaurantDto.fromEntity(restaurant);
}
// find by slug with nested categories + items + template
async findMyRestaurant(id: string, userId: string) {
await this.verifyOwnership(userId, id);
return this.findOne(id);
}
async findBySlug(slug: string) {
const restaurant = await this.prisma.restaurant.findUnique({
where: { slug },
@@ -70,19 +77,18 @@ export class RestaurantService {
},
},
});
if (!restaurant) throw new NotFoundException('Restaurant not found');
return RestaurantDto.fromEntity(restaurant);
}
async update(id: string, dto: UpdateRestaurantDto, userId: string) {
const restaurant = await this.findOne(id);
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
return RestaurantDto.fromEntity(restaurant);
}
async update(id: string, dto: UpdateRestaurantDto, userId: string) {
await this.verifyOwnership(userId, id);
return this.prisma.restaurant.update({
const updated = await this.prisma.restaurant.update({
where: { id },
data: {
name: dto.name,
@@ -92,15 +98,12 @@ export class RestaurantService {
template: dto.template ?? undefined,
},
});
return RestaurantDto.fromEntity(updated);
}
async remove(id: string, userId: string) {
const restaurant = await this.findOne(id);
if (!restaurant) throw new NotFoundException('Restaurant not found');
await this.verifyOwnership(userId, id);
return this.prisma.restaurant.delete({ where: { id } });
}
}
+8 -1
View File
@@ -1,5 +1,10 @@
import { Controller, Get, Param, Delete, UseGuards } from '@nestjs/common';
import { AuthGuard, Session } from '@thallesp/nestjs-better-auth';
import {
AuthGuard,
Session,
AllowAnonymous,
Roles,
} from '@thallesp/nestjs-better-auth';
import type { UserSession } from '@thallesp/nestjs-better-auth';
import { UserService } from './user.service';
@@ -8,11 +13,13 @@ import { UserService } from './user.service';
export class UserController {
constructor(private readonly userService: UserService) {}
// @AllowAnonymous()
@Get()
async getAllUsers() {
return this.userService.getAllUsers();
}
@Roles(['admin'])
@Get(':id')
async getUserById(@Param('id') id: string) {
return this.userService.getUserById(id);