From 492f520a14feb7c650d67eaf894d962e2a8c8d13 Mon Sep 17 00:00:00 2001 From: Angela Date: Fri, 10 Oct 2025 15:53:05 +0200 Subject: [PATCH 1/8] create book and image upload in cloudinary --- package-lock.json | 71 ++++++++++++++++++- package.json | 6 +- .../migration.sql | 64 +++++++++++++++++ prisma/schema.prisma | 44 ++++++++++++ prisma/seed.ts | 12 +++- src/app.module.ts | 2 + src/books/book.controller.ts | 26 +++++++ src/books/book.service.ts | 22 ++++++ src/books/books.module.ts | 11 +++ src/books/dto/create-book.dto.ts | 31 ++++++++ src/cloudinary/cloudinary.config.ts | 19 +++++ src/cloudinary/cloudinary.module.ts | 8 +++ src/cloudinary/cloudinary.provider.ts | 13 ++++ src/main.ts | 2 + 14 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20251010102032_add_book_price_location_transactions/migration.sql create mode 100644 src/books/book.controller.ts create mode 100644 src/books/book.service.ts create mode 100644 src/books/books.module.ts create mode 100644 src/books/dto/create-book.dto.ts create mode 100644 src/cloudinary/cloudinary.config.ts create mode 100644 src/cloudinary/cloudinary.module.ts create mode 100644 src/cloudinary/cloudinary.provider.ts diff --git a/package-lock.json b/package-lock.json index 13a48e6..d3f54f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,9 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cloudinary": "^1.41.3", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", @@ -28,8 +31,9 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", - "@types/express": "^5.0.0", + "@types/express": "^5.0.3", "@types/jest": "^29.5.2", + "@types/multer": "^2.0.0", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", @@ -2396,6 +2400,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", @@ -3940,6 +3954,30 @@ "node": ">=0.8" } }, + "node_modules/cloudinary": { + "version": "1.41.3", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz", + "integrity": "sha512-4o84y+E7dbif3lMns+p3UW6w6hLHEifbX/7zBJvaih1E9QNMZITENQ14GPYJC4JmhygYXsuuBb9bRA3xWEoOfg==", + "license": "MIT", + "dependencies": { + "cloudinary-core": "^2.13.0", + "core-js": "^3.30.1", + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/cloudinary-core": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/cloudinary-core/-/cloudinary-core-2.14.0.tgz", + "integrity": "sha512-L+kjoYgU+5wyiPkSnmeCbmtT6DwSyYUN/WoI/fEb6Xsx2gtB3iuf/50W0SvcQkeKzllfH5Knh8I4ST924DkkRw==", + "license": "MIT", + "peerDependencies": { + "lodash": ">=4.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4111,6 +4149,17 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7514,6 +7563,15 @@ "node": ">= 10.16.0" } }, + "node_modules/multer-storage-cloudinary": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multer-storage-cloudinary/-/multer-storage-cloudinary-4.0.0.tgz", + "integrity": "sha512-25lm9R6o5dWrHLqLvygNX+kBOxprzpmZdnVKH4+r68WcfCt8XV6xfQaMuAg+kUE5Xmr8mJNA4gE0AcBj9FJyWA==", + "license": "MIT", + "peerDependencies": { + "cloudinary": "^1.21.0" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -8298,6 +8356,17 @@ ], "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/package.json b/package.json index 91a5e3a..e0ebbed 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cloudinary": "^1.41.3", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", @@ -39,8 +42,9 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", - "@types/express": "^5.0.0", + "@types/express": "^5.0.3", "@types/jest": "^29.5.2", + "@types/multer": "^2.0.0", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", diff --git a/prisma/migrations/20251010102032_add_book_price_location_transactions/migration.sql b/prisma/migrations/20251010102032_add_book_price_location_transactions/migration.sql new file mode 100644 index 0000000..6006e7c --- /dev/null +++ b/prisma/migrations/20251010102032_add_book_price_location_transactions/migration.sql @@ -0,0 +1,64 @@ +/* + Warnings: + + - Added the required column `location` to the `Book` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "public"."Book" ADD COLUMN "location" TEXT NOT NULL, +ADD COLUMN "price" INTEGER NOT NULL DEFAULT 10; + +-- CreateTable +CREATE TABLE "public"."Transaction" ( + "id" TEXT NOT NULL, + "bookId" TEXT NOT NULL, + "buyerId" TEXT NOT NULL, + "sellerId" TEXT, + "amount" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."AdminAction" ( + "id" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminAction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."Invite" ( + "id" TEXT NOT NULL, + "inviterId" TEXT NOT NULL, + "inviteeId" TEXT, + "code" TEXT NOT NULL, + "redeemed" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Invite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Invite_code_key" ON "public"."Invite"("code"); + +-- AddForeignKey +ALTER TABLE "public"."Transaction" ADD CONSTRAINT "Transaction_bookId_fkey" FOREIGN KEY ("bookId") REFERENCES "public"."Book"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Transaction" ADD CONSTRAINT "Transaction_buyerId_fkey" FOREIGN KEY ("buyerId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Transaction" ADD CONSTRAINT "Transaction_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AdminAction" ADD CONSTRAINT "AdminAction_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Invite" ADD CONSTRAINT "Invite_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7d1a4ba..4daaa6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,10 @@ model User { reviewsGiven Review[] @relation("Author") reviewsReceived Review[] @relation("Target") wallet Wallet? @relation("UserWallet") + buyerTransactions Transaction[] @relation("BuyerTransactions") + sellerTransactions Transaction[] @relation("SellerTransactions") + adminActions AdminAction[] @relation("UserAdminActions") + invitesSent Invite[] @relation("Inviter") } @@ -39,11 +43,14 @@ model Book { available Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + price Int @default(10) + location String ownerId String owner User @relation("UserBooks", fields: [ownerId], references: [id]) exchange Exchange? // Relación 1:1 opcional + transactions Transaction[] } model Wallet { @@ -104,6 +111,43 @@ model Review { target User @relation("Target", fields: [targetId], references: [id]) } +model Transaction { + id String @id @default(cuid()) + bookId String + buyerId String + sellerId String? + amount Int + createdAt DateTime @default(now()) + + book Book @relation(fields: [bookId], references: [id]) + buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id]) + seller User? @relation("SellerTransactions", fields: [sellerId], references: [id]) +} + +model AdminAction { + id String @id @default(cuid()) + adminId String + targetType String // "book", "exchange", "user" + targetId String + action String // "approve", "ban", "resolve" + notes String? + createdAt DateTime @default(now()) + admin User @relation("UserAdminActions", fields: [adminId], references: [id]) +} + +model Invite { + id String @id @default(cuid()) + inviterId String + inviteeId String? + code String @unique + redeemed Boolean @default(false) + createdAt DateTime @default(now()) + inviter User @relation("Inviter", fields: [inviterId], references: [id]) +} + + + + // Enums enum Role { diff --git a/prisma/seed.ts b/prisma/seed.ts index 295e1ed..0a826bc 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -51,7 +51,11 @@ async function main() { description: 'Distopía clásica sobre vigilancia y control.', imageUrl: 'https://example.com/1984.jpg', condition: BookCondition.GOOD, - ownerId: user1.id, + price: 10, + location: 'Tarragona', + owner: { + connect: { id: user1.id }, + }, }, }); @@ -63,7 +67,11 @@ async function main() { description: 'Un cuento filosófico para todas las edades.', imageUrl: 'https://example.com/principito.jpg', condition: BookCondition.FAIR, - ownerId: user2.id, + price: 10, + location: 'Tarragona', + owner: { + connect: { id: user2.id }, + }, }, }); diff --git a/src/app.module.ts b/src/app.module.ts index 0790c62..95a7769 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from 'prisma/prisma.module'; +import { BookModule } from './books/books.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -11,6 +12,7 @@ import { PrismaModule } from 'prisma/prisma.module'; }), AuthModule, PrismaModule, + BookModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts new file mode 100644 index 0000000..a9a6e97 --- /dev/null +++ b/src/books/book.controller.ts @@ -0,0 +1,26 @@ +import { + Controller, + Post, + Body, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { storage } from 'src/cloudinary/cloudinary.config'; +import { CreateBookDto } from './dto/create-book.dto'; +import { BookService } from './book.service'; +import { Express } from 'express'; // ← Asegurate de tener esto + +@Controller('books') +export class BookController { + constructor(private readonly bookService: BookService) {} + + @Post('create') + @UseInterceptors(FileInterceptor('image', { storage })) + async createBook( + @UploadedFile() file: Express.Multer.File, + @Body() body: CreateBookDto, + ) { + return this.bookService.createBook({ ...body, imageUrl: file?.path }); + } +} diff --git a/src/books/book.service.ts b/src/books/book.service.ts new file mode 100644 index 0000000..7928fb1 --- /dev/null +++ b/src/books/book.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'prisma/prisma.service'; +import { CreateBookDto } from './dto/create-book.dto'; + +@Injectable() +export class BookService { + constructor(private readonly prisma: PrismaService) {} + + async createBook(data: CreateBookDto & { imageUrl?: string }) { + const { ownerId, price, ...rest } = data; + + return this.prisma.book.create({ + data: { + ...rest, + price: Number(price), + owner: { + connect: { id: ownerId }, + }, + }, + }); + } +} diff --git a/src/books/books.module.ts b/src/books/books.module.ts new file mode 100644 index 0000000..20963da --- /dev/null +++ b/src/books/books.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { BookController } from './book.controller'; +import { BookService } from './book.service'; +import { PrismaModule } from 'prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [BookController], + providers: [BookService], +}) +export class BookModule {} diff --git a/src/books/dto/create-book.dto.ts b/src/books/dto/create-book.dto.ts new file mode 100644 index 0000000..56a9bf6 --- /dev/null +++ b/src/books/dto/create-book.dto.ts @@ -0,0 +1,31 @@ +// src/books/dto/create-book.dto.ts +import { IsString, IsOptional, IsInt, IsEnum } from 'class-validator'; +import { BookCondition } from '@prisma/client'; + +export class CreateBookDto { + @IsString() + title: string; + + @IsString() + author: string; + + @IsOptional() + @IsString() + isbn?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(BookCondition) + condition: BookCondition; + + @IsString() + location: string; + + @IsInt() + price: number; + + @IsString() + ownerId: string; +} diff --git a/src/cloudinary/cloudinary.config.ts b/src/cloudinary/cloudinary.config.ts new file mode 100644 index 0000000..4beb8a0 --- /dev/null +++ b/src/cloudinary/cloudinary.config.ts @@ -0,0 +1,19 @@ +import { CloudinaryStorage } from 'multer-storage-cloudinary'; +import { v2 as cloudinary } from 'cloudinary'; +import * as dotenv from 'dotenv'; +dotenv.config(); // ← Asegura que las variables estén disponibles + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export const storage = new CloudinaryStorage({ + cloudinary, + params: (req, file) => ({ + public_id: `${Date.now()}-${file.originalname.replace(/\s+/g, '-')}`, + allowed_formats: ['jpg', 'png', 'jpeg'], + folder: 'bookloop', + }), +}); diff --git a/src/cloudinary/cloudinary.module.ts b/src/cloudinary/cloudinary.module.ts new file mode 100644 index 0000000..e1ba227 --- /dev/null +++ b/src/cloudinary/cloudinary.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CloudinaryProvider } from './cloudinary.provider'; + +@Module({ + providers: [CloudinaryProvider], + exports: ['Cloudinary'], +}) +export class CloudinaryModule {} diff --git a/src/cloudinary/cloudinary.provider.ts b/src/cloudinary/cloudinary.provider.ts new file mode 100644 index 0000000..71ef0cb --- /dev/null +++ b/src/cloudinary/cloudinary.provider.ts @@ -0,0 +1,13 @@ +import { v2 as cloudinary } from 'cloudinary'; + +export const CloudinaryProvider = { + provide: 'Cloudinary', + useFactory: () => { + cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + }); + return cloudinary; + }, +}; diff --git a/src/main.ts b/src/main.ts index f992719..c7c1658 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import * as dotenv from 'dotenv'; +dotenv.config(); async function bootstrap() { const app = await NestFactory.create(AppModule); From f37c7b56bca6b902f109fde381d19154e8dca029 Mon Sep 17 00:00:00 2001 From: Angela Date: Mon, 13 Oct 2025 15:18:39 +0200 Subject: [PATCH 2/8] create swagger documentation --- package-lock.json | 175 ++++++++++++++++++++----- package.json | 4 +- src/auth/auth.controller.ts | 18 ++- src/auth/dto/register.dto.ts | 4 + src/books/book.controller.ts | 38 ++++++ src/books/book.service.ts | 7 +- src/books/dto/create-book.dto.ts | 16 ++- src/books/enums/book-condition.enum.ts | 5 + src/cloudinary/cloudinary.config.ts | 2 +- src/main.ts | 23 +++- 10 files changed, 249 insertions(+), 43 deletions(-) create mode 100644 src/books/enums/book-condition.enum.ts diff --git a/package-lock.json b/package-lock.json index d3f54f0..78b6ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.0", "@prisma/client": "^6.13.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", @@ -25,7 +26,8 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -1592,6 +1594,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -1831,6 +1839,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", @@ -1886,6 +1914,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/swagger": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz", @@ -2102,6 +2163,13 @@ "@prisma/debug": "6.13.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3185,7 +3253,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -3369,6 +3436,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", + "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -3471,9 +3548,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -3491,9 +3568,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -3711,9 +3789,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001731", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", - "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "version": "1.0.30001750", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", + "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", "dev": true, "funding": [ { @@ -4513,9 +4591,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.198", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.198.tgz", - "integrity": "sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==", + "version": "1.5.234", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz", + "integrity": "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==", "dev": true, "license": "ISC" }, @@ -7012,7 +7090,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7674,9 +7751,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", "dev": true, "license": "MIT" }, @@ -9445,6 +9522,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", + "license": "Apache-2.0" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/swagger-ui-express/node_modules/swagger-ui-dist": { + "version": "5.29.4", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.4.tgz", + "integrity": "sha512-gJFDz/gyLOCQtWwAgqs6Rk78z9ONnqTnlW11gimG9nLap8drKa3AJBKpzIQMIjl5PD2Ix+Tn+mc/tfoT2tgsng==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -9472,13 +9579,17 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { @@ -10234,9 +10345,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.0.tgz", - "integrity": "sha512-B4t+nJqytPeuZlHuIKTbalhljIFXeNRqrUGAQgTGlfOl2lXXKXw+yZu6bicycP+PUlM44CxBjCFD6aciKFT3LQ==", + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", + "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", "peer": true, @@ -10249,9 +10360,9 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", + "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.2", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -10261,10 +10372,10 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", + "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { @@ -10330,9 +10441,9 @@ } }, "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index e0ebbed..71c75b2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.0", "@prisma/client": "^6.13.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", @@ -36,7 +37,8 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 77de0ac..7af8325 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,11 @@ import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBody, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -6,18 +13,24 @@ import { Request } from 'express'; import { AuthGuard } from '@nestjs/passport'; import { User } from '@prisma/client'; +@ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @Post('register') + @ApiOperation({ summary: 'Registrar nuevo usuario' }) + @ApiBody({ type: RegisterDto }) + @ApiResponse({ status: 201, description: 'Usuario registrado correctamente' }) async register(@Body() registerDto: RegisterDto) { const user = await this.authService.register(registerDto); - // eslint-disable-next-line @typescript-eslint/no-unused-vars return user; } @Post('login') + @ApiOperation({ summary: 'Iniciar sesión' }) + @ApiBody({ type: LoginDto }) + @ApiResponse({ status: 200, description: 'Inicio de sesión exitoso' }) async login(@Body() loginDto: LoginDto) { const user = await this.authService.validateUser( loginDto.email, @@ -28,6 +41,9 @@ export class AuthController { @UseGuards(AuthGuard('jwt')) @Get('whoami') + @ApiOperation({ summary: 'Obtener usuario autenticado' }) + @ApiBearerAuth() + @ApiResponse({ status: 200, description: 'Usuario autenticado' }) async whoAmI(@Req() req: Request & { user: User }) { return req.user; } diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index 2043ac4..e1ba396 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -1,12 +1,16 @@ import { IsEmail, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class RegisterDto { + @ApiProperty({ example: 'Angela' }) @IsString() name: string; + @ApiProperty({ example: 'angela@example.com' }) @IsEmail() email: string; + @ApiProperty({ example: 'securePassword123' }) @IsString() @MinLength(6) password: string; diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts index a9a6e97..b03a73f 100644 --- a/src/books/book.controller.ts +++ b/src/books/book.controller.ts @@ -10,12 +10,50 @@ import { storage } from 'src/cloudinary/cloudinary.config'; import { CreateBookDto } from './dto/create-book.dto'; import { BookService } from './book.service'; import { Express } from 'express'; // ← Asegurate de tener esto +import { + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, + ApiConsumes, +} from '@nestjs/swagger'; +@ApiTags('books') @Controller('books') export class BookController { constructor(private readonly bookService: BookService) {} @Post('create') + @ApiOperation({ summary: 'Create new book' }) + @ApiResponse({ status: 201, description: 'Book create success' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + title: { type: 'string', example: 'El nombre del viento' }, + author: { type: 'string', example: 'Patrick Rothfuss' }, + isbn: { type: 'string', example: '9788498382540' }, + description: { type: 'string', example: 'Fantasía épica y poética' }, + condition: { type: 'string', enum: ['GOOD', 'EXCELLENT', 'POOR'] }, + location: { type: 'string', example: 'Tarragona' }, + price: { type: 'integer', example: 15 }, + ownerId: { type: 'string', example: 'cmgkw5oee0000o9f8gmgg1sgi' }, + image: { + type: 'string', + format: 'binary', + }, + }, + required: [ + 'title', + 'author', + 'condition', + 'location', + 'price', + 'ownerId', + ], + }, + }) @UseInterceptors(FileInterceptor('image', { storage })) async createBook( @UploadedFile() file: Express.Multer.File, diff --git a/src/books/book.service.ts b/src/books/book.service.ts index 7928fb1..5ddaf10 100644 --- a/src/books/book.service.ts +++ b/src/books/book.service.ts @@ -1,17 +1,22 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from 'prisma/prisma.service'; import { CreateBookDto } from './dto/create-book.dto'; +import { BookCondition } from '@prisma/client'; @Injectable() export class BookService { constructor(private readonly prisma: PrismaService) {} async createBook(data: CreateBookDto & { imageUrl?: string }) { - const { ownerId, price, ...rest } = data; + const { ownerId, price, condition, ...rest } = data; + + const prismaCondition = + BookCondition[condition as keyof typeof BookCondition]; return this.prisma.book.create({ data: { ...rest, + condition: prismaCondition, price: Number(price), owner: { connect: { id: ownerId }, diff --git a/src/books/dto/create-book.dto.ts b/src/books/dto/create-book.dto.ts index 56a9bf6..f1c4222 100644 --- a/src/books/dto/create-book.dto.ts +++ b/src/books/dto/create-book.dto.ts @@ -1,31 +1,39 @@ -// src/books/dto/create-book.dto.ts import { IsString, IsOptional, IsInt, IsEnum } from 'class-validator'; -import { BookCondition } from '@prisma/client'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { BookConditionEnum } from '../enums/book-condition.enum'; export class CreateBookDto { + @ApiProperty({ example: 'El nombre del viento' }) @IsString() title: string; + @ApiProperty({ example: 'Patrick Rothfuss' }) @IsString() author: string; + @ApiPropertyOptional({ example: '9788498382540' }) @IsOptional() @IsString() isbn?: string; + @ApiPropertyOptional({ example: 'Fantasía épica y poética' }) @IsOptional() @IsString() description?: string; - @IsEnum(BookCondition) - condition: BookCondition; + // @ApiProperty({ enum: BookConditionEnum, example: BookConditionEnum.GOOD }) + @IsEnum(BookConditionEnum) + condition: BookConditionEnum; + @ApiProperty({ example: 'Tarragona' }) @IsString() location: string; + @ApiProperty({ example: 15 }) @IsInt() price: number; + @ApiProperty({ example: 'cmgkw5oee0000o9f8gmgg1sgi' }) @IsString() ownerId: string; } diff --git a/src/books/enums/book-condition.enum.ts b/src/books/enums/book-condition.enum.ts new file mode 100644 index 0000000..ebf2fca --- /dev/null +++ b/src/books/enums/book-condition.enum.ts @@ -0,0 +1,5 @@ +export enum BookConditionEnum { + GOOD = 'GOOD', + EXCELLENT = 'EXCELLENT', + FAIR = 'FAIR', +} diff --git a/src/cloudinary/cloudinary.config.ts b/src/cloudinary/cloudinary.config.ts index 4beb8a0..8e4ffa1 100644 --- a/src/cloudinary/cloudinary.config.ts +++ b/src/cloudinary/cloudinary.config.ts @@ -12,7 +12,7 @@ cloudinary.config({ export const storage = new CloudinaryStorage({ cloudinary, params: (req, file) => ({ - public_id: `${Date.now()}-${file.originalname.replace(/\s+/g, '-')}`, + public_id: `${Date.now()}-${file.originalname.replace(/\.[^/.]+$/, '').replace(/\s+/g, '-')}`, allowed_formats: ['jpg', 'png', 'jpeg'], folder: 'bookloop', }), diff --git a/src/main.ts b/src/main.ts index c7c1658..473d41d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,33 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as dotenv from 'dotenv'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; dotenv.config(); async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors({ origin: 'http://localhost:3000', - methods: ['GET', 'POST', 'PUT', 'DELETE'], + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], credentials: true, }); - app.setGlobalPrefix('api'); - await app.listen(process.env.PORT ?? 3000); + try { + const config = new DocumentBuilder() + .setTitle('Bookloop API') + .setDescription( + 'Documentación interactiva de la API para gestión de libros', + ) + .setVersion('1.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } catch (error) { + console.error('Error al generar Swagger:', error); + } + const port = process.env.PORT ?? 3000; + await app.listen(port); + console.log(`API levantada en el puerto ${port}`); } bootstrap(); From c805e2fc74f7c9fc42f45561e809f62bec525777 Mon Sep 17 00:00:00 2001 From: Angela Date: Mon, 13 Oct 2025 16:23:01 +0200 Subject: [PATCH 3/8] craete enums for swagger --- src/books/book.controller.ts | 3 +++ src/books/dto/create-book.dto.ts | 18 ++++++++---------- .../enums => common}/book-condition.enum.ts | 3 ++- src/common/exchange-status.enum.ts | 7 +++++++ src/common/index.ts | 4 ++++ src/common/movement-type.enum.ts | 4 ++++ src/common/role.enum.ts | 5 +++++ 7 files changed, 33 insertions(+), 11 deletions(-) rename src/{books/enums => common}/book-condition.enum.ts (68%) create mode 100644 src/common/exchange-status.enum.ts create mode 100644 src/common/index.ts create mode 100644 src/common/movement-type.enum.ts create mode 100644 src/common/role.enum.ts diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts index b03a73f..1bc1e90 100644 --- a/src/books/book.controller.ts +++ b/src/books/book.controller.ts @@ -47,10 +47,13 @@ export class BookController { required: [ 'title', 'author', + 'isbn', + 'description', 'condition', 'location', 'price', 'ownerId', + 'image', ], }, }) diff --git a/src/books/dto/create-book.dto.ts b/src/books/dto/create-book.dto.ts index f1c4222..276b802 100644 --- a/src/books/dto/create-book.dto.ts +++ b/src/books/dto/create-book.dto.ts @@ -1,6 +1,6 @@ -import { IsString, IsOptional, IsInt, IsEnum } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { BookConditionEnum } from '../enums/book-condition.enum'; +import { IsString, IsInt, IsEnum } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { BookConditionEnum } from 'src/common'; export class CreateBookDto { @ApiProperty({ example: 'El nombre del viento' }) @@ -11,17 +11,15 @@ export class CreateBookDto { @IsString() author: string; - @ApiPropertyOptional({ example: '9788498382540' }) - @IsOptional() + @ApiProperty({ example: '9788498382540' }) @IsString() - isbn?: string; + isbn: string; - @ApiPropertyOptional({ example: 'Fantasía épica y poética' }) - @IsOptional() + @ApiProperty({ example: 'Fantasía épica y poética' }) @IsString() - description?: string; + description: string; - // @ApiProperty({ enum: BookConditionEnum, example: BookConditionEnum.GOOD }) + @ApiProperty({ enum: BookConditionEnum, example: BookConditionEnum.GOOD }) @IsEnum(BookConditionEnum) condition: BookConditionEnum; diff --git a/src/books/enums/book-condition.enum.ts b/src/common/book-condition.enum.ts similarity index 68% rename from src/books/enums/book-condition.enum.ts rename to src/common/book-condition.enum.ts index ebf2fca..191d460 100644 --- a/src/books/enums/book-condition.enum.ts +++ b/src/common/book-condition.enum.ts @@ -1,5 +1,6 @@ export enum BookConditionEnum { + NEW = 'NEW', GOOD = 'GOOD', - EXCELLENT = 'EXCELLENT', FAIR = 'FAIR', + POOR = 'POOR', } diff --git a/src/common/exchange-status.enum.ts b/src/common/exchange-status.enum.ts new file mode 100644 index 0000000..d020774 --- /dev/null +++ b/src/common/exchange-status.enum.ts @@ -0,0 +1,7 @@ +export enum ExchangeStatusEnum { + PENDING = 'PENDING', + ACCEPTED = 'ACCEPTED', + IN_TRANSIT = 'IN_TRANSIT', + DELIVERED = 'DELIVERED', + CANCELED = 'CANCELED', +} diff --git a/src/common/index.ts b/src/common/index.ts new file mode 100644 index 0000000..7d68750 --- /dev/null +++ b/src/common/index.ts @@ -0,0 +1,4 @@ +export * from './book-condition.enum'; +export * from './role.enum'; +export * from './exchange-status.enum'; +export * from './movement-type.enum'; diff --git a/src/common/movement-type.enum.ts b/src/common/movement-type.enum.ts new file mode 100644 index 0000000..24af89c --- /dev/null +++ b/src/common/movement-type.enum.ts @@ -0,0 +1,4 @@ +export enum MovementTypeEnum { + INCOME = 'INCOME', + EXPENSE = 'EXPENSE', +} diff --git a/src/common/role.enum.ts b/src/common/role.enum.ts new file mode 100644 index 0000000..1eb3dd6 --- /dev/null +++ b/src/common/role.enum.ts @@ -0,0 +1,5 @@ +export enum RoleEnum { + USER = 'USER', + ADMIN = 'ADMIN', + BANNED = 'BANNED', +} From 6f11cdd7b4bb3c9fa9c1a74dcdd6c7b262d0bd7d Mon Sep 17 00:00:00 2001 From: Angela Date: Mon, 13 Oct 2025 16:41:44 +0200 Subject: [PATCH 4/8] clean ports --- kill-ports.sh | 20 +++++++++++ package.json | 4 ++- src/auth/dto/login.dto.ts | 3 ++ src/main.ts | 75 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 kill-ports.sh diff --git a/kill-ports.sh b/kill-ports.sh new file mode 100644 index 0000000..a1860e1 --- /dev/null +++ b/kill-ports.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Script para matar procesos en puertos de desarrollo +PORTS=(3001 3002 3003 3004 3005) + +echo "🔍 Buscando procesos en puertos de desarrollo..." + +for PORT in "${PORTS[@]}"; do + PID=$(netstat -ano | grep ":$PORT " | grep LISTENING | awk '{print $5}' | head -1) + if [ ! -z "$PID" ]; then + echo "🔥 Terminando proceso PID $PID en puerto $PORT" + taskkill.exe //PID $PID //F 2>/dev/null + else + echo "✅ Puerto $PORT ya está libre" + fi +done + +echo "🎉 Limpieza completada!" +echo "Puertos disponibles:" +netstat -ano | grep -E ":(3001|3002|3003|3004|3005)" | grep LISTENING || echo "Todos los puertos están libres ✅" \ No newline at end of file diff --git a/package.json b/package.json index 71c75b2..98f1320 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "kill-ports": "bash kill-ports.sh", + "clean-start": "npm run kill-ports && npm run start:dev" }, "dependencies": { "@nestjs/common": "^10.0.0", diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index ace8fc7..0a63fc8 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,9 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength } from 'class-validator'; export class LoginDto { + @ApiProperty({ example: 'email@gmail.com' }) @IsEmail() email: string; + @ApiProperty({ example: 'Password123' }) @IsString() @MinLength(6) password: string; diff --git a/src/main.ts b/src/main.ts index 473d41d..4cc7063 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,10 +2,14 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as dotenv from 'dotenv'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { INestApplication } from '@nestjs/common'; dotenv.config(); async function bootstrap() { const app = await NestFactory.create(AppModule); + + // Habilitar graceful shutdown hooks + app.enableShutdownHooks(); app.enableCors({ origin: 'http://localhost:3000', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], @@ -26,8 +30,73 @@ async function bootstrap() { } catch (error) { console.error('Error al generar Swagger:', error); } - const port = process.env.PORT ?? 3000; - await app.listen(port); - console.log(`API levantada en el puerto ${port}`); + const desiredPort = Number(process.env.PORT) || 3002; + const port = await listenOnAvailablePort(app, desiredPort); + if (port !== desiredPort) { + console.warn( + `El puerto ${desiredPort} estaba en uso. API levantada en el puerto ${port}`, + ); + } else { + console.log(`API levantada en el puerto ${port}`); + } + + // Manejar señales de sistema para graceful shutdown + const gracefulShutdown = async (signal: string) => { + console.log( + `\n🔄 Recibida señal ${signal}, cerrando servidor gracefully...`, + ); + try { + await app.close(); + console.log('✅ Servidor cerrado correctamente'); + process.exit(0); + } catch (error) { + console.error('❌ Error al cerrar servidor:', error); + process.exit(1); + } + }; + + // Capturar señales de terminación + process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Ctrl+C + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); // Terminación del sistema + process.on('SIGUSR2', () => gracefulShutdown('SIGUSR2')); // nodemon restart + + // Manejar errores no capturados + process.on('unhandledRejection', (reason, promise) => { + console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason); + }); + + process.on('uncaughtException', (error) => { + console.error('❌ Uncaught Exception:', error); + gracefulShutdown('UNCAUGHT_EXCEPTION'); + }); } bootstrap(); + +async function listenOnAvailablePort( + app: INestApplication, + startPort: number, +): Promise { + const maxRetries = 20; + for (let offset = 0; offset <= maxRetries; offset++) { + const portToTry = startPort + offset; + try { + await app.listen(portToTry, '0.0.0.0'); + return portToTry; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'EADDRINUSE') { + console.warn( + `Puerto ${portToTry} en uso, intentando con ${portToTry + 1}...`, + ); + continue; + } + throw error; + } + } + + await app.listen(0, '0.0.0.0'); + const address = app.getHttpServer().address(); + if (typeof address === 'object' && address) { + return address.port; + } + throw new Error('No se pudo determinar un puerto disponible'); +} From 59610b2daa899b209897388409ddc869ae258845 Mon Sep 17 00:00:00 2001 From: Angela Date: Mon, 13 Oct 2025 17:51:07 +0200 Subject: [PATCH 5/8] get all books --- package-lock.json | 195 ++++++++++++++++++++++++++ package.json | 2 + src/app.module.ts | 2 +- src/auth/auth.module.ts | 2 +- src/auth/auth.service.ts | 2 +- src/books/book.controller.ts | 14 +- src/books/book.service.ts | 17 ++- src/books/books.module.ts | 2 +- src/books/dto/books-response.dto.ts | 53 +++++++ src/books/dto/pagination-query.dto.ts | 25 ++++ src/main.ts | 94 +------------ 11 files changed, 312 insertions(+), 96 deletions(-) create mode 100644 src/books/dto/books-response.dto.ts create mode 100644 src/books/dto/pagination-query.dto.ts diff --git a/package-lock.json b/package-lock.json index 78b6ea6..0d6d48c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", + "kill-port": "^2.0.1", "prettier": "^3.0.0", "prisma": "^6.13.0", "source-map-support": "^0.5.21", @@ -52,6 +53,7 @@ "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" } @@ -2570,6 +2572,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/superagent": { "version": "8.1.9", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", @@ -4557,6 +4573,16 @@ "node": ">= 0.4" } }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5688,6 +5714,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-them-args": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/get-them-args/-/get-them-args-1.3.2.tgz", + "integrity": "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==", + "dev": true, + "license": "MIT" + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -7225,6 +7258,20 @@ "json-buffer": "3.0.1" } }, + "node_modules/kill-port": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kill-port/-/kill-port-2.0.1.tgz", + "integrity": "sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-them-args": "1.3.2", + "shell-exec": "1.0.2" + }, + "bin": { + "kill-port": "cli.js" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -9103,6 +9150,13 @@ "node": ">=8" } }, + "node_modules/shell-exec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shell-exec/-/shell-exec-1.0.2.tgz", + "integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==", + "dev": true, + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -10005,6 +10059,127 @@ } } }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/ts-node-dev/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/ts-node-dev/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-node-dev/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ts-node-dev/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-node-dev/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -10046,6 +10221,26 @@ "node": ">=4" } }, + "node_modules/tsconfig/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 98f1320..2311eee 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", + "kill-port": "^2.0.1", "prettier": "^3.0.0", "prisma": "^6.13.0", "source-map-support": "^0.5.21", @@ -65,6 +66,7 @@ "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" }, diff --git a/src/app.module.ts b/src/app.module.ts index 95a7769..8a9b09a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,7 +3,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { ConfigModule } from '@nestjs/config'; -import { PrismaModule } from 'prisma/prisma.module'; +import { PrismaModule } from '../prisma/prisma.module'; import { BookModule } from './books/books.module'; @Module({ imports: [ diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index ae6bcdb..b999291 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,7 +5,7 @@ import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; import { PassportModule } from '@nestjs/passport'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { PrismaModule } from 'prisma/prisma.module'; +import { PrismaModule } from '../../prisma/prisma.module'; @Module({ imports: [ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 6504726..9298557 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; -import { PrismaService } from 'prisma/prisma.service'; +import { PrismaService } from '../../prisma/prisma.service'; @Injectable() export class AuthService { diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts index 1bc1e90..16053b0 100644 --- a/src/books/book.controller.ts +++ b/src/books/book.controller.ts @@ -4,9 +4,11 @@ import { Body, UploadedFile, UseInterceptors, + Get, + Query, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { storage } from 'src/cloudinary/cloudinary.config'; +// import { storage } from '../cloudinary/cloudinary.config'; import { CreateBookDto } from './dto/create-book.dto'; import { BookService } from './book.service'; import { Express } from 'express'; // ← Asegurate de tener esto @@ -17,6 +19,9 @@ import { ApiTags, ApiConsumes, } from '@nestjs/swagger'; +import { BookResponseDto } from './dto/books-response.dto'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; +import { storage } from 'src/cloudinary/cloudinary.config'; @ApiTags('books') @Controller('books') @@ -64,4 +69,11 @@ export class BookController { ) { return this.bookService.createBook({ ...body, imageUrl: file?.path }); } + + @ApiResponse({ status: 200, type: BookResponseDto, isArray: true }) + @Get() + @ApiOperation({ summary: 'Get all books' }) + async findAll() { + return this.bookService.findAll(); + } } diff --git a/src/books/book.service.ts b/src/books/book.service.ts index 5ddaf10..1fbef1a 100644 --- a/src/books/book.service.ts +++ b/src/books/book.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from 'prisma/prisma.service'; +import { PrismaService } from '../../prisma/prisma.service'; import { CreateBookDto } from './dto/create-book.dto'; import { BookCondition } from '@prisma/client'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; @Injectable() export class BookService { @@ -24,4 +25,18 @@ export class BookService { }, }); } + + async findAll() { + return this.prisma.book.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + owner: { + select: { + id: true, + name: true, + }, + }, + }, + }); + } } diff --git a/src/books/books.module.ts b/src/books/books.module.ts index 20963da..a27718d 100644 --- a/src/books/books.module.ts +++ b/src/books/books.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { BookController } from './book.controller'; import { BookService } from './book.service'; -import { PrismaModule } from 'prisma/prisma.module'; +import { PrismaModule } from '../../prisma/prisma.module'; @Module({ imports: [PrismaModule], diff --git a/src/books/dto/books-response.dto.ts b/src/books/dto/books-response.dto.ts new file mode 100644 index 0000000..d8dfe2f --- /dev/null +++ b/src/books/dto/books-response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BookResponseDto { + @ApiProperty({ example: 'cmgkw5oee0000o9f8gmgg1sgi' }) + id: string; + + @ApiProperty({ example: 'El nombre del viento' }) + title: string; + + @ApiProperty({ example: 'Patrick Rothfuss' }) + author: string; + + @ApiProperty({ example: '9788498382540', required: false }) + isbn?: string; + + @ApiProperty({ example: 'Fantasía épica y poética', required: false }) + description?: string; + + @ApiProperty({ + example: 'https://res.cloudinary.com/.../image.jpg', + required: false, + }) + imageUrl?: string; + + @ApiProperty({ example: 'GOOD', enum: ['NEW', 'GOOD', 'FAIR', 'POOR'] }) + condition: string; + + @ApiProperty({ example: true }) + available: boolean; + + @ApiProperty({ example: 15 }) + price: number; + + @ApiProperty({ example: 'Tarragona' }) + location: string; + + @ApiProperty({ example: '2025-10-13T15:03:07.000Z' }) + createdAt: Date; + + @ApiProperty({ example: '2025-10-13T15:03:07.000Z' }) + updatedAt: Date; + + @ApiProperty({ + example: { + id: 'user123', + name: 'Ángela García', + }, + }) + owner: { + id: string; + name: string; + }; +} diff --git a/src/books/dto/pagination-query.dto.ts b/src/books/dto/pagination-query.dto.ts new file mode 100644 index 0000000..1fae55d --- /dev/null +++ b/src/books/dto/pagination-query.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsInt, Min } from 'class-validator'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ + example: 1, + description: 'Número de página (desde 1)', + }) + @Type(() => Number) + @IsOptional() + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + example: 10, + description: 'Cantidad de resultados por página', + }) + @Type(() => Number) + @IsOptional() + @IsInt() + @Min(1) + limit?: number = 10; +} diff --git a/src/main.ts b/src/main.ts index 4cc7063..b758f75 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,102 +1,16 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as dotenv from 'dotenv'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; -import { INestApplication } from '@nestjs/common'; dotenv.config(); async function bootstrap() { const app = await NestFactory.create(AppModule); - - // Habilitar graceful shutdown hooks - app.enableShutdownHooks(); app.enableCors({ origin: 'http://localhost:3000', - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + methods: ['GET', 'POST', 'PUT', 'DELETE'], credentials: true, }); - try { - const config = new DocumentBuilder() - .setTitle('Bookloop API') - .setDescription( - 'Documentación interactiva de la API para gestión de libros', - ) - .setVersion('1.0') - .addBearerAuth() - .build(); - - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document); - } catch (error) { - console.error('Error al generar Swagger:', error); - } - const desiredPort = Number(process.env.PORT) || 3002; - const port = await listenOnAvailablePort(app, desiredPort); - if (port !== desiredPort) { - console.warn( - `El puerto ${desiredPort} estaba en uso. API levantada en el puerto ${port}`, - ); - } else { - console.log(`API levantada en el puerto ${port}`); - } - - // Manejar señales de sistema para graceful shutdown - const gracefulShutdown = async (signal: string) => { - console.log( - `\n🔄 Recibida señal ${signal}, cerrando servidor gracefully...`, - ); - try { - await app.close(); - console.log('✅ Servidor cerrado correctamente'); - process.exit(0); - } catch (error) { - console.error('❌ Error al cerrar servidor:', error); - process.exit(1); - } - }; - - // Capturar señales de terminación - process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Ctrl+C - process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); // Terminación del sistema - process.on('SIGUSR2', () => gracefulShutdown('SIGUSR2')); // nodemon restart - - // Manejar errores no capturados - process.on('unhandledRejection', (reason, promise) => { - console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason); - }); - - process.on('uncaughtException', (error) => { - console.error('❌ Uncaught Exception:', error); - gracefulShutdown('UNCAUGHT_EXCEPTION'); - }); -} -bootstrap(); - -async function listenOnAvailablePort( - app: INestApplication, - startPort: number, -): Promise { - const maxRetries = 20; - for (let offset = 0; offset <= maxRetries; offset++) { - const portToTry = startPort + offset; - try { - await app.listen(portToTry, '0.0.0.0'); - return portToTry; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === 'EADDRINUSE') { - console.warn( - `Puerto ${portToTry} en uso, intentando con ${portToTry + 1}...`, - ); - continue; - } - throw error; - } - } - - await app.listen(0, '0.0.0.0'); - const address = app.getHttpServer().address(); - if (typeof address === 'object' && address) { - return address.port; - } - throw new Error('No se pudo determinar un puerto disponible'); + app.setGlobalPrefix('api'); + await app.listen(process.env.PORT ?? 3000); } +bootstrap(); \ No newline at end of file From 04e76c12f4f18211a7bc1e045ff1096dfe28af2f Mon Sep 17 00:00:00 2001 From: Angela Date: Wed, 15 Oct 2025 17:30:08 +0200 Subject: [PATCH 6/8] pagination in getAll books --- src/books/book.controller.ts | 13 +++--- src/books/book.service.ts | 46 +++++++++++++++---- src/books/dto/paginated-books-response.dto.ts | 42 +++++++++++++++++ src/main.ts | 12 ++++- 4 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 src/books/dto/paginated-books-response.dto.ts diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts index 16053b0..66f8d9c 100644 --- a/src/books/book.controller.ts +++ b/src/books/book.controller.ts @@ -8,10 +8,9 @@ import { Query, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -// import { storage } from '../cloudinary/cloudinary.config'; import { CreateBookDto } from './dto/create-book.dto'; import { BookService } from './book.service'; -import { Express } from 'express'; // ← Asegurate de tener esto +import { Express } from 'express'; import { ApiBody, ApiOperation, @@ -19,8 +18,8 @@ import { ApiTags, ApiConsumes, } from '@nestjs/swagger'; -import { BookResponseDto } from './dto/books-response.dto'; import { PaginationQueryDto } from './dto/pagination-query.dto'; +import { PaginatedBooksResponseDto } from './dto/paginated-books-response.dto'; import { storage } from 'src/cloudinary/cloudinary.config'; @ApiTags('books') @@ -70,10 +69,10 @@ export class BookController { return this.bookService.createBook({ ...body, imageUrl: file?.path }); } - @ApiResponse({ status: 200, type: BookResponseDto, isArray: true }) + @ApiResponse({ status: 200, type: PaginatedBooksResponseDto }) @Get() - @ApiOperation({ summary: 'Get all books' }) - async findAll() { - return this.bookService.findAll(); + @ApiOperation({ summary: 'Get all books with pagination' }) + async findAll(@Query() paginationQuery: PaginationQueryDto) { + return this.bookService.findAll(paginationQuery); } } diff --git a/src/books/book.service.ts b/src/books/book.service.ts index 1fbef1a..996c774 100644 --- a/src/books/book.service.ts +++ b/src/books/book.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { CreateBookDto } from './dto/create-book.dto'; -import { BookCondition } from '@prisma/client'; import { PaginationQueryDto } from './dto/pagination-query.dto'; +import { BookCondition } from '@prisma/client'; @Injectable() export class BookService { @@ -26,17 +26,43 @@ export class BookService { }); } - async findAll() { - return this.prisma.book.findMany({ - orderBy: { createdAt: 'desc' }, - include: { - owner: { - select: { - id: true, - name: true, + async findAll(paginationQuery?: PaginationQueryDto) { + const { page = 1, limit = 10 } = paginationQuery || {}; + const pageNum = Number(page); + const limitNum = Number(limit); + const skip = (pageNum - 1) * limitNum; + + const [books, totalBooks] = await this.prisma.$transaction([ + this.prisma.book.findMany({ + skip, + take: limitNum, + orderBy: { createdAt: 'desc' }, + include: { + owner: { + select: { + id: true, + name: true, + }, }, }, + }), + this.prisma.book.count(), + ]); + + const totalPages = Math.ceil(totalBooks / limitNum); + const hasNextPage = pageNum < totalPages; + const hasPreviousPage = pageNum > 1; + + return { + data: books, + pagination: { + currentPage: pageNum, + totalPages, + totalBooks, + limit: limitNum, + hasNextPage, + hasPreviousPage, }, - }); + }; } } diff --git a/src/books/dto/paginated-books-response.dto.ts b/src/books/dto/paginated-books-response.dto.ts new file mode 100644 index 0000000..ec3431e --- /dev/null +++ b/src/books/dto/paginated-books-response.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BookResponseDto } from './books-response.dto'; + +export class PaginationInfoDto { + @ApiProperty({ example: 1, description: 'Página actual' }) + currentPage: number; + + @ApiProperty({ example: 5, description: 'Total de páginas disponibles' }) + totalPages: number; + + @ApiProperty({ example: 50, description: 'Total de libros' }) + totalBooks: number; + + @ApiProperty({ example: 10, description: 'Límite de resultados por página' }) + limit: number; + + @ApiProperty({ + example: true, + description: 'Indica si existe página siguiente', + }) + hasNextPage: boolean; + + @ApiProperty({ + example: false, + description: 'Indica si existe página anterior', + }) + hasPreviousPage: boolean; +} + +export class PaginatedBooksResponseDto { + @ApiProperty({ + type: [BookResponseDto], + description: 'Lista de libros en la página actual', + }) + data: BookResponseDto[]; + + @ApiProperty({ + type: PaginationInfoDto, + description: 'Información de paginación', + }) + pagination: PaginationInfoDto; +} diff --git a/src/main.ts b/src/main.ts index b758f75..1519ece 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,20 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; import * as dotenv from 'dotenv'; dotenv.config(); async function bootstrap() { const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }) + ); + app.enableCors({ origin: 'http://localhost:3000', methods: ['GET', 'POST', 'PUT', 'DELETE'], @@ -13,4 +23,4 @@ async function bootstrap() { app.setGlobalPrefix('api'); await app.listen(process.env.PORT ?? 3000); } -bootstrap(); \ No newline at end of file +bootstrap(); From 87ad7aa360507e6207d4f2aeef49f8c18c207cd5 Mon Sep 17 00:00:00 2001 From: Angela Date: Wed, 15 Oct 2025 18:04:49 +0200 Subject: [PATCH 7/8] create endpoint update book by admin and owner --- src/auth/auth.service.ts | 2 +- src/auth/jwt.strategy.ts | 2 +- src/books/book.controller.ts | 19 +++++++++++ src/books/book.service.ts | 34 ++++++++++++++++++- src/books/dto/create-book.dto.ts | 2 +- src/books/dto/update-book.dto.ts | 6 ++++ src/common/{ => enums}/book-condition.enum.ts | 0 .../{ => enums}/exchange-status.enum.ts | 0 src/common/{ => enums}/index.ts | 0 src/common/{ => enums}/movement-type.enum.ts | 0 src/common/{ => enums}/role.enum.ts | 0 .../types/request-with-user.interface.ts | 8 +++++ 12 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 src/books/dto/update-book.dto.ts rename src/common/{ => enums}/book-condition.enum.ts (100%) rename src/common/{ => enums}/exchange-status.enum.ts (100%) rename src/common/{ => enums}/index.ts (100%) rename src/common/{ => enums}/movement-type.enum.ts (100%) rename src/common/{ => enums}/role.enum.ts (100%) create mode 100644 src/common/types/request-with-user.interface.ts diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 9298557..ffa3232 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -40,7 +40,7 @@ export class AuthService { } async login(user: any) { - const payload = { sub: user.id, email: user.email, name: user.name }; + const payload = { sub: user.id, email: user.email, name: user.name, role: user.role }; return { token: this.jwtService.sign(payload) }; } } diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 736e117..97e0a83 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -14,6 +14,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: any) { - return { id: payload.sub, email: payload.email, name: payload.name }; + return { id: payload.sub, email: payload.email, name: payload.name, role: payload.role }; } } diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts index 66f8d9c..661526c 100644 --- a/src/books/book.controller.ts +++ b/src/books/book.controller.ts @@ -6,6 +6,10 @@ import { UseInterceptors, Get, Query, + Put, + Param, + Req, + UseGuards, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { CreateBookDto } from './dto/create-book.dto'; @@ -21,6 +25,9 @@ import { import { PaginationQueryDto } from './dto/pagination-query.dto'; import { PaginatedBooksResponseDto } from './dto/paginated-books-response.dto'; import { storage } from 'src/cloudinary/cloudinary.config'; +import { UpdateBookDto } from './dto/update-book.dto'; +import { RequestWithUser } from 'src/common/types/request-with-user.interface'; +import { AuthGuard } from '@nestjs/passport'; @ApiTags('books') @Controller('books') @@ -75,4 +82,16 @@ export class BookController { async findAll(@Query() paginationQuery: PaginationQueryDto) { return this.bookService.findAll(paginationQuery); } + + @Put(':id') + @UseGuards(AuthGuard('jwt')) + @ApiOperation({ summary: 'Edit book for ID' }) + @ApiResponse({ status: 200, description: 'Book updated success' }) + async updateBook( + @Param('id') id: string, + @Body() body: UpdateBookDto, + @Req() req: RequestWithUser, + ) { + return this.bookService.updateBook(id, body, req.user.id, req.user.role); + } } diff --git a/src/books/book.service.ts b/src/books/book.service.ts index 996c774..b78802c 100644 --- a/src/books/book.service.ts +++ b/src/books/book.service.ts @@ -1,8 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { CreateBookDto } from './dto/create-book.dto'; import { PaginationQueryDto } from './dto/pagination-query.dto'; import { BookCondition } from '@prisma/client'; +import { UpdateBookDto } from './dto/update-book.dto'; @Injectable() export class BookService { @@ -65,4 +70,31 @@ export class BookService { }, }; } + + async updateBook( + id: string, + data: UpdateBookDto, + userId: string, + role: 'USER' | 'ADMIN', + ) { + const book = await this.prisma.book.findUnique({ + where: { id }, + }); + + if (!book) { + throw new NotFoundException(`Book with ID ${id} not found`); + } + + const isOwner = book.ownerId === userId; + const isAdmin = role === 'ADMIN'; + + if (!isOwner && !isAdmin) { + throw new ForbiddenException('You are not permitted to edit this book.'); + } + + return this.prisma.book.update({ + where: { id }, + data, + }); + } } diff --git a/src/books/dto/create-book.dto.ts b/src/books/dto/create-book.dto.ts index 276b802..55546b8 100644 --- a/src/books/dto/create-book.dto.ts +++ b/src/books/dto/create-book.dto.ts @@ -1,6 +1,6 @@ import { IsString, IsInt, IsEnum } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -import { BookConditionEnum } from 'src/common'; +import { BookConditionEnum } from 'src/common/enums'; export class CreateBookDto { @ApiProperty({ example: 'El nombre del viento' }) diff --git a/src/books/dto/update-book.dto.ts b/src/books/dto/update-book.dto.ts new file mode 100644 index 0000000..93e17bb --- /dev/null +++ b/src/books/dto/update-book.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, OmitType } from '@nestjs/swagger'; +import { CreateBookDto } from './create-book.dto'; + +export class UpdateBookDto extends PartialType( + OmitType(CreateBookDto, ['ownerId']), +) {} diff --git a/src/common/book-condition.enum.ts b/src/common/enums/book-condition.enum.ts similarity index 100% rename from src/common/book-condition.enum.ts rename to src/common/enums/book-condition.enum.ts diff --git a/src/common/exchange-status.enum.ts b/src/common/enums/exchange-status.enum.ts similarity index 100% rename from src/common/exchange-status.enum.ts rename to src/common/enums/exchange-status.enum.ts diff --git a/src/common/index.ts b/src/common/enums/index.ts similarity index 100% rename from src/common/index.ts rename to src/common/enums/index.ts diff --git a/src/common/movement-type.enum.ts b/src/common/enums/movement-type.enum.ts similarity index 100% rename from src/common/movement-type.enum.ts rename to src/common/enums/movement-type.enum.ts diff --git a/src/common/role.enum.ts b/src/common/enums/role.enum.ts similarity index 100% rename from src/common/role.enum.ts rename to src/common/enums/role.enum.ts diff --git a/src/common/types/request-with-user.interface.ts b/src/common/types/request-with-user.interface.ts new file mode 100644 index 0000000..a79bd5e --- /dev/null +++ b/src/common/types/request-with-user.interface.ts @@ -0,0 +1,8 @@ +import { Request } from 'express'; + +export interface RequestWithUser extends Request { + user: { + id: string; + role: 'USER' | 'ADMIN'; + }; +} From d5ef3d1c93949f980231fef9ab6286905d59ee3d Mon Sep 17 00:00:00 2001 From: Angela Date: Wed, 15 Oct 2025 18:17:12 +0200 Subject: [PATCH 8/8] create endpoint delete book for admin and owner --- src/books/book.controller.ts | 9 +++++++++ src/books/book.service.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts index 661526c..b715a1d 100644 --- a/src/books/book.controller.ts +++ b/src/books/book.controller.ts @@ -10,6 +10,7 @@ import { Param, Req, UseGuards, + Delete, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { CreateBookDto } from './dto/create-book.dto'; @@ -94,4 +95,12 @@ export class BookController { ) { return this.bookService.updateBook(id, body, req.user.id, req.user.role); } + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + @ApiOperation({ summary: 'Delete book for ID' }) + @ApiResponse({ status: 200, description: 'Book deleted success' }) + async deleteBook(@Param('id') id: string, @Req() req: RequestWithUser) { + return this.bookService.deleteBook(id, req.user.id, req.user.role); + } } diff --git a/src/books/book.service.ts b/src/books/book.service.ts index b78802c..1afb9f1 100644 --- a/src/books/book.service.ts +++ b/src/books/book.service.ts @@ -92,9 +92,39 @@ export class BookService { throw new ForbiddenException('You are not permitted to edit this book.'); } - return this.prisma.book.update({ + const updatedBook = await this.prisma.book.update({ where: { id }, data, }); + + return { + message: 'Book updated successfully', + book: updatedBook, + }; + } + + async deleteBook(id: string, userId: string, role: 'USER' | 'ADMIN') { + const book = await this.prisma.book.findUnique({ + where: { id }, + }); + + if (!book) { + throw new NotFoundException(`Book with ID ${id} not found`); + } + + const isOwner = book.ownerId === userId; + const isAdmin = role === 'ADMIN'; + + if (!isOwner && !isAdmin) { + throw new ForbiddenException( + 'You are not permitted to delete this book.', + ); + } + await this.prisma.book.delete({ where: { id } }); + + return { + message: 'Book deleted successfully', + deletedBookId: id, + }; } }