diff --git a/package-lock.json b/package-lock.json index ee2b729..3737787 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@prisma/client": "^6.18.0", "@thallesp/nestjs-better-auth": "^2.1.0", "better-auth": "^1.3.8", + "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "jose": "3.16.0", "reflect-metadata": "^0.2.2", @@ -5029,6 +5030,12 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, "node_modules/class-validator": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", diff --git a/package.json b/package.json index e0a7aae..7977770 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@prisma/client": "^6.18.0", "@thallesp/nestjs-better-auth": "^2.1.0", "better-auth": "^1.3.8", + "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "jose": "3.16.0", "reflect-metadata": "^0.2.2", diff --git a/prisma/migrations/20251108181537_add_better_auth_models/migration.sql b/prisma/migrations/20251108181537_add_better_auth_models/migration.sql new file mode 100644 index 0000000..5fde6d0 --- /dev/null +++ b/prisma/migrations/20251108181537_add_better_auth_models/migration.sql @@ -0,0 +1,39 @@ +/* + Warnings: + + - You are about to drop the column `accessToken` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `accountId` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `expiresAt` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `password` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `providerId` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `refreshToken` on the `Account` table. All the data in the column will be lost. + - A unique constraint covering the columns `[token]` on the table `Verification` will be added. If there are existing duplicate values, this will fail. + - Added the required column `providerAccountId` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `type` to the `Account` table without a default value. This is not possible if the table is not empty. + - Made the column `provider` on table `Account` required. This step will fail if there are existing NULL values in that column. + - Added the required column `value` to the `Verification` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Account" DROP COLUMN "accessToken", +DROP COLUMN "accountId", +DROP COLUMN "expiresAt", +DROP COLUMN "password", +DROP COLUMN "providerId", +DROP COLUMN "refreshToken", +ADD COLUMN "access_token" TEXT, +ADD COLUMN "expires_at" INTEGER, +ADD COLUMN "id_token" TEXT, +ADD COLUMN "providerAccountId" TEXT NOT NULL, +ADD COLUMN "refresh_token" TEXT, +ADD COLUMN "scope" TEXT, +ADD COLUMN "session_state" TEXT, +ADD COLUMN "token_type" TEXT, +ADD COLUMN "type" TEXT NOT NULL, +ALTER COLUMN "provider" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Verification" ADD COLUMN "value" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Verification_token_key" ON "Verification"("token"); diff --git a/prisma/migrations/20251108181747_add_better_auth_models/migration.sql b/prisma/migrations/20251108181747_add_better_auth_models/migration.sql new file mode 100644 index 0000000..dab2842 --- /dev/null +++ b/prisma/migrations/20251108181747_add_better_auth_models/migration.sql @@ -0,0 +1,116 @@ +/* + Warnings: + + - You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Verification` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Account" DROP CONSTRAINT "Account_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Restaurant" DROP CONSTRAINT "Restaurant_ownerId_fkey"; + +-- DropForeignKey +ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey"; + +-- DropTable +DROP TABLE "Account"; + +-- DropTable +DROP TABLE "Session"; + +-- DropTable +DROP TABLE "User"; + +-- DropTable +DROP TABLE "Verification"; + +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + + CONSTRAINT "user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "password" TEXT, + + CONSTRAINT "account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_token_key" ON "verification"("token"); + +-- AddForeignKey +ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Restaurant" ADD CONSTRAINT "Restaurant_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251108182131_add_better_auth_models/migration.sql b/prisma/migrations/20251108182131_add_better_auth_models/migration.sql new file mode 100644 index 0000000..c214564 --- /dev/null +++ b/prisma/migrations/20251108182131_add_better_auth_models/migration.sql @@ -0,0 +1,101 @@ +/* + Warnings: + + - You are about to drop the `account` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `session` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `user` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `verification` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Restaurant" DROP CONSTRAINT "Restaurant_ownerId_fkey"; + +-- DropForeignKey +ALTER TABLE "account" DROP CONSTRAINT "account_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "session" DROP CONSTRAINT "session_userId_fkey"; + +-- DropTable +DROP TABLE "account"; + +-- DropTable +DROP TABLE "session"; + +-- DropTable +DROP TABLE "user"; + +-- DropTable +DROP TABLE "verification"; + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "provider" TEXT, + "accountId" TEXT NOT NULL, + "password" TEXT, + "accessToken" TEXT, + "refreshToken" TEXT, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "token" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Restaurant" ADD CONSTRAINT "Restaurant_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251108183423_add_better_auth_models/migration.sql b/prisma/migrations/20251108183423_add_better_auth_models/migration.sql new file mode 100644 index 0000000..14a227b --- /dev/null +++ b/prisma/migrations/20251108183423_add_better_auth_models/migration.sql @@ -0,0 +1,106 @@ +/* + Warnings: + + - You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Verification` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Account" DROP CONSTRAINT "Account_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Restaurant" DROP CONSTRAINT "Restaurant_ownerId_fkey"; + +-- DropForeignKey +ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey"; + +-- DropTable +DROP TABLE "Account"; + +-- DropTable +DROP TABLE "Session"; + +-- DropTable +DROP TABLE "User"; + +-- DropTable +DROP TABLE "Verification"; + +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + + CONSTRAINT "user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "provider" TEXT, + "accountId" TEXT NOT NULL, + "password" TEXT, + "accessToken" TEXT, + "refreshToken" TEXT, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + + CONSTRAINT "account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "token" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); + +-- AddForeignKey +ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Restaurant" ADD CONSTRAINT "Restaurant_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251109113728_add_better_auth_models/migration.sql b/prisma/migrations/20251109113728_add_better_auth_models/migration.sql new file mode 100644 index 0000000..a77221d --- /dev/null +++ b/prisma/migrations/20251109113728_add_better_auth_models/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "session" ADD COLUMN "impersonatedBy" TEXT; + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "banExpires" TIMESTAMP(3), +ADD COLUMN "banReason" TEXT, +ADD COLUMN "banned" BOOLEAN DEFAULT false, +ALTER COLUMN "role" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ac2a5d..094ad82 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,3 +1,4 @@ + generator client { provider = "prisma-client-js" } @@ -7,99 +8,114 @@ datasource db { url = "postgresql://postgres:root@localhost:5432/zemenu?schema=public" } - model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique - emailVerified Boolean @default(false) + email String? @unique + emailVerified Boolean @default(false) image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - role String @default("user") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - restaurants Restaurant[] - accounts Account[] - sessions Session[] + restaurants Restaurant[] + accounts Account[] + sessions Session[] + + role String? @default("user") + banned Boolean? @default(false) + banReason String? + banExpires DateTime? + + @@map("user") } model Account { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id]) - providerId String - provider String? - accountId String - password String? - accessToken String? - refreshToken String? - expiresAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + providerId String + provider String? + accountId String + password String? + accessToken String? + refreshToken String? + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + @@map("account") +} model Session { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id]) - expiresAt DateTime - token String? @unique - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + expiresAt DateTime + token String? @unique + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + impersonatedBy String? + + @@map("session") +} model Verification { id String @id @default(cuid()) identifier String - token String + token String? expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + value String + + @@map("verification") } - model Restaurant { - id String @id @default(uuid()) - name String - slug String @unique // used as /domain.com/slug - description String? - logo String? // image URL - ownerId String - owner User @relation(fields: [ownerId], references: [id]) + id String @id @default(uuid()) + name String + slug String @unique // used as /domain.com/slug + description String? + logo String? // image URL + ownerId String + owner User @relation(fields: [ownerId], references: [id]) - template String? + template String? - categories Category[] - items MenuItem[] - createdAt DateTime @default(now()) + categories Category[] + items MenuItem[] + createdAt DateTime @default(now()) } model Category { - id String @id @default(uuid()) - name String - - restaurantId String - restaurant Restaurant @relation(fields: [restaurantId], references: [id]) - - items MenuItem[] -} - -model MenuItem { - id String @id @default(uuid()) - name String - description String? - price Float - image String? - - categoryId String? - category Category? @relation(fields: [categoryId], references: [id]) + id String @id @default(uuid()) + name String restaurantId String restaurant Restaurant @relation(fields: [restaurantId], references: [id]) - createdAt DateTime @default(now()) -} \ No newline at end of file + items MenuItem[] +} + +model MenuItem { + id String @id @default(uuid()) + name String + description String? + price Float + image String? + + categoryId String? + category Category? @relation(fields: [categoryId], references: [id]) + + restaurantId String + restaurant Restaurant @relation(fields: [restaurantId], references: [id]) + + createdAt DateTime @default(now()) +} diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 28f4175..f308699 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -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!, }, diff --git a/src/category/category.controller.ts b/src/category/category.controller.ts index d9e2fe3..0b8b52a 100644 --- a/src/category/category.controller.ts +++ b/src/category/category.controller.ts @@ -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); } } diff --git a/src/category/category.service.ts b/src/category/category.service.ts index 231e86a..98585d1 100644 --- a/src/category/category.service.ts +++ b/src/category/category.service.ts @@ -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 } }); } } diff --git a/src/category/dto/create-category.dto.ts b/src/category/dto/create-category.dto.ts index b22d312..4f77b95 100644 --- a/src/category/dto/create-category.dto.ts +++ b/src/category/dto/create-category.dto.ts @@ -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; diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..61a83ac --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -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(); + + 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, + }); + } +} diff --git a/src/item/dto/create-item.dto.ts b/src/item/dto/create-item.dto.ts index a5133bc..572b9b6 100644 --- a/src/item/dto/create-item.dto.ts +++ b/src/item/dto/create-item.dto.ts @@ -23,11 +23,7 @@ export class CreateItemDto { @IsString() image?: string; - @IsOptional() + @IsNotEmpty() @IsUUID() - categoryId?: string; - - @IsNotEmpty() - @IsUUID() - restaurantId: string; + categoryId: string; } diff --git a/src/item/item.controller.ts b/src/item/item.controller.ts index 3802d0a..21057f2 100644 --- a/src/item/item.controller.ts +++ b/src/item/item.controller.ts @@ -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); diff --git a/src/item/item.module.ts b/src/item/item.module.ts index 717d4be..e87c43b 100644 --- a/src/item/item.module.ts +++ b/src/item/item.module.ts @@ -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], }) diff --git a/src/item/item.service.ts b/src/item/item.service.ts index eedd137..85c0445 100644 --- a/src/item/item.service.ts +++ b/src/item/item.service.ts @@ -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, + ); } } diff --git a/src/main.ts b/src/main.ts index d9d1979..450af47 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); + + // 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(); diff --git a/src/restaurant/restaurant.controller.ts b/src/restaurant/restaurant.controller.ts index b45a26a..b371906 100644 --- a/src/restaurant/restaurant.controller.ts +++ b/src/restaurant/restaurant.controller.ts @@ -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); diff --git a/src/restaurant/restaurant.service.ts b/src/restaurant/restaurant.service.ts index 06b6c12..cffaa7c 100644 --- a/src/restaurant/restaurant.service.ts +++ b/src/restaurant/restaurant.service.ts @@ -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 } }); } } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index f2e27eb..bfc6368 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -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);