diff --git a/.env.example b/.env.example index 018066e39..e92f41532 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,33 @@ -NODE_ENV=development PROFILE=local -PORT=5000 -HOST= -REDIS_PORT= 6379 -DB_SSL=true +NODE_ENV=development + +PORT=3008 JWT_SECRET=someSecrets JWT_EXPIRY_TIMEFRAME=3600 + + REDIS_HOST=localhost REDIS_PORT=6379 -DB_TYPE= + +DB_TYPE=postgres DB_USERNAME= DB_PASSWORD= -DB_HOST= -DB_NAME=hng +DB_HOST=localhost +DB_DATABASE= DB_ENTITIES=dist/src/modules/**/entities/**/*.entity{.ts,.js} DB_MIGRATIONS=dist/db/migrations/*{.ts,.js} -POSGRES_USER=$DB_USERNAME -POST -JWT_SECRET=gsgs -JWT_EXPIRY_TIMEFRAME=1500000 -DB_SSL=false -JWT_REFRESH_SECRET=bbp -JWT_REFRESH_EXPIRY_TIMEFRAME=15 -GOOGLE_REDIRECT_URI= + +JWT_SECRET=someSecrets +JWT_EXPIRY_TIMEFRAME=3600 + +ADMIN_SECRET_KEY=sometext + GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_ID= -OAUTH_LOGIN_REDIRECT= -SMTP_HOST= + +SMTP_HOST=sandbox.smtp.mailtrap.io SMTP_PORT=587 -SERVER_NAME=Boilerplate +SERVER_NAME=api SMTP_USER= -SMTP_PASSWORD= -FRONTEND_URL= -ADMIN_SECRET= -SUPPORT_EMAIL= -AUTH_PASSWORD= -BASE_URL= -FLUTTERWAVE_SECRET_KEY= -FLUTTERWAVE_BASE_URL= +SMTP_PASSWORD= \ No newline at end of file diff --git a/.env.local b/.env.local index 6771b01b7..7648ab281 100644 --- a/.env.local +++ b/.env.local @@ -31,7 +31,7 @@ NODE_ENV=development PORT=3000 DB_USERNAME=username -DB_PASSWORD=password +DB_PASSWORD=password123 DB_TYPE=postgres DB_NAME=database DB_HOST=localhost diff --git a/.gitignore b/.gitignore index b5f15abc6..550356a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -402,12 +402,12 @@ dist # User specific ignores todo.txt +/.vscode/ .vscode/ # Docker compose docker-compose.yml data/ -<<<<<<< HEAD data/ docker-compose.yml package-lock.json @@ -419,16 +419,12 @@ docker-compose.yml data/ .dev.env - -======= - -# Docker compose -docker-compose.yml -data/ ->>>>>>> d080450 (chore: created a docker-compose.yml file and updated the gitignore) +/compose/compose.yaml +compose/compose.yaml data/ docker-compose.yml +package-lock.json/ package-lock.json .dev.env diff --git a/compose/compose.yaml b/compose/compose.yaml deleted file mode 100644 index 335a2e94f..000000000 --- a/compose/compose.yaml +++ /dev/null @@ -1,65 +0,0 @@ -name: nestjs - -services: - app: - image: ${COMPOSE_PROJECT_NAME} - build: . - env_file: - - .env - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: 'wget -qO- http://app:${PORT}' - interval: 10s - timeout: 10s - retries: 3 - - db: - image: postgres:16-alpine - env_file: - - .env - environment: - - POSTGRES_USER=${DB_USERNAME} - - POSTGRES_PASSWORD=${DB_PASSWORD} - - POSTGRES_DB=${DB_NAME} - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: 'pg_isready -U postgres' - interval: 5s - timeout: 5s - retries: 3 - restart: always - - redis: - image: redis:7-alpine - env_file: - - .env - volumes: - - redis_data:/data - healthcheck: - test: 'redis-cli ping | grep PONG' - interval: 5s - timeout: 5s - retries: 3 - restart: always - - nginx: - image: nginx:alpine - volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf - depends_on: - app: - condition: service_healthy - healthcheck: - test: 'wget -qO- http://nginx:80' - interval: 5s - timeout: 5s - retries: 3 - -volumes: - postgres_data: - redis_data: diff --git a/src/modules/products/current-user.decorator.ts b/src/modules/products/current-user.decorator.ts new file mode 100644 index 000000000..2cb9757b7 --- /dev/null +++ b/src/modules/products/current-user.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; // This works if AuthGuard attaches user object to request +}); diff --git a/src/modules/products/dto/create-review.dto.ts b/src/modules/products/dto/create-review.dto.ts new file mode 100644 index 000000000..2e31569bc --- /dev/null +++ b/src/modules/products/dto/create-review.dto.ts @@ -0,0 +1,11 @@ +import { IsInt, IsString, Max, Min } from 'class-validator'; + +export class CreateReviewDto { + @IsInt() + @Min(1) + @Max(5) + rating: number; + + @IsString() + review: string; +} diff --git a/src/modules/products/dto/product-response.dto.ts b/src/modules/products/dto/product-response.dto.ts new file mode 100644 index 000000000..287ef3c1c --- /dev/null +++ b/src/modules/products/dto/product-response.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ProductResponseDto { + id: string; + name: string; + description: string; + averageRating: number; + totalReviews: number; + + @ApiProperty({ type: () => [ReviewSummaryDto] }) + recentReviews: ReviewSummaryDto[]; +} + +// Define ReviewSummaryDto **inside the same file**, avoiding unnecessary imports +class ReviewSummaryDto { + rating: number; + review: string; + createdBy: string; + createdAt: Date; +} diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts index 945aca9a6..ded381c15 100644 --- a/src/modules/products/entities/product.entity.ts +++ b/src/modules/products/entities/product.entity.ts @@ -4,6 +4,7 @@ import { Comment } from '../../../modules/comments/entities/comments.entity'; import { Organisation } from '../../../modules/organisations/entities/organisations.entity'; import { Cart } from '../../dashboard/entities/cart.entity'; import { OrderItem } from '../../dashboard/entities/order-items.entity'; +import { Review } from './review.entity'; export enum StockStatusType { IN_STOCK = 'in stock', @@ -40,6 +41,9 @@ export class Product extends AbstractBaseEntity { @Column({ type: 'int', nullable: false, default: 0 }) quantity: number; + @OneToMany(() => Review, review => review.product) + reviews: Review[]; + @Column({ type: 'enum', enum: ProductSizeType, diff --git a/src/modules/products/entities/review.entity.ts b/src/modules/products/entities/review.entity.ts new file mode 100644 index 000000000..3d228954c --- /dev/null +++ b/src/modules/products/entities/review.entity.ts @@ -0,0 +1,26 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity() +export class Review { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'int', width: 1 }) + rating: number; + + @Column({ type: 'text' }) + review: string; + + @Column() + createdBy: string; // This will be user ID + + @ManyToOne(() => Product, product => product.reviews, { onDelete: 'CASCADE' }) + product: Product; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/products/products.controller.ts b/src/modules/products/products.controller.ts index cd2741d29..9b7ad01d2 100644 --- a/src/modules/products/products.controller.ts +++ b/src/modules/products/products.controller.ts @@ -18,6 +18,10 @@ import { ProductsService } from './products.service'; import { UpdateProductDTO } from './dto/update-product.dto'; import { isUUID } from 'class-validator'; import { GetTotalProductsResponseDto } from './dto/get-total-products.dto'; +import { CreateReviewDto } from './dto/create-review.dto'; +import { User } from '../user/entities/user.entity'; +import { AuthGuard } from '../../guards/auth.guard'; +import { CurrentUser } from './current-user.decorator'; import { skipAuth } from '@shared/helpers/skipAuth'; import { OwnershipGuard } from '@guards/authorization.guard'; import { AddCommentDto } from '@modules/comments/dto/add-comment.dto'; @@ -180,4 +184,30 @@ export class ProductsController { async getProductStock(@Param('productId') productId: string) { return this.productsService.getProductStock(productId); } + + @ApiBearerAuth() + @UseGuards(AuthGuard) + @Post(':productId/review') + @ApiOperation({ summary: 'Submit or Update Product Review' }) + async submitReview( + @Param('productId') productId: string, + @CurrentUser() user: User, // Use the correct custom decorator + @Body() dto: CreateReviewDto // Consistent DTO name + ) { + return this.productsService.submitReview(user.id, productId, dto); + } + + @Delete(':productId/reviews') + @UseGuards(AuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete Product Review' }) + async deleteReview(@Param('productId') productId: string, @CurrentUser() user: User) { + return this.productsService.deleteReview(user.id, productId); + } + + @Get('view/reviews/:productId') + @ApiOperation({ summary: 'Get Product Details (with Reviews)' }) + async getProductDetails(@Param('productId') productId: string) { + return this.productsService.getProductDetails(productId); + } } diff --git a/src/modules/products/products.module.ts b/src/modules/products/products.module.ts index 26251ef3b..e16f7d21e 100644 --- a/src/modules/products/products.module.ts +++ b/src/modules/products/products.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Product } from './entities/product.entity'; import { ProductsController } from './products.controller'; import { ProductsService } from './products.service'; +import { Review } from './entities/review.entity'; import { UserModule } from '@modules/user/user.module'; import { Cart } from '@modules/dashboard/entities/cart.entity'; import { OrderItem } from '@modules/dashboard/entities/order-items.entity'; @@ -13,7 +14,6 @@ import { ProductVariant } from './entities/product-variant.entity'; import { Organisation } from '@modules/organisations/entities/organisations.entity'; import { Order } from '@modules/dashboard/entities/order.entity'; import { Comment } from '@modules/comments/entities/comments.entity'; - @Module({ imports: [ TypeOrmModule.forFeature([ @@ -27,6 +27,7 @@ import { Comment } from '@modules/comments/entities/comments.entity'; Order, OrderItem, Cart, + Review, ]), UserModule, ], diff --git a/src/modules/products/products.service.ts b/src/modules/products/products.service.ts index e41dd2e59..b760c7713 100644 --- a/src/modules/products/products.service.ts +++ b/src/modules/products/products.service.ts @@ -17,8 +17,12 @@ import { User } from '../user/entities/user.entity'; import { CreateProductRequestDto } from './dto/create-product.dto'; import { UpdateProductDTO } from './dto/update-product.dto'; import { Product, ProductSizeType, StockStatusType } from './entities/product.entity'; +import { CreateReviewDto } from './dto/create-review.dto'; +import { Review } from './entities/review.entity'; +import { ProductResponseDto } from './dto/product-response.dto'; import { CustomHttpException } from '@shared/helpers/custom-http-filter'; + interface SearchCriteria { name?: string; category?: string; @@ -33,7 +37,8 @@ export class ProductsService { @InjectRepository(Product) private productRepository: Repository, @InjectRepository(Organisation) private organisationRepository: Repository, @InjectRepository(Comment) private commentRepository: Repository, - @InjectRepository(User) private userRepository: Repository + @InjectRepository(User) private userRepository: Repository, + @InjectRepository(Review) private reviewRepository: Repository // Add this line ) {} async createProduct(id: string, dto: CreateProductRequestDto) { @@ -340,4 +345,72 @@ export class ProductsService { }, }; } + + async submitReview(userId: string, productId: string, dto: CreateReviewDto) { + const product = await this.productRepository.findOne({ + where: { id: productId }, + relations: ['reviews'], + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + let review = product.reviews.find(r => r.createdBy === userId); + if (review) { + // Update existing review + review.rating = dto.rating; + review.review = dto.review; + review.updatedAt = new Date(); + } else { + // Create new review + review = this.reviewRepository.create({ ...dto, createdBy: userId, product }); + } + + await this.reviewRepository.save(review); + return { message: 'Review saved successfully' }; + } + + async deleteReview(userId: string, productId: string) { + const review = await this.reviewRepository.findOne({ where: { product: { id: productId }, createdBy: userId } }); + if (!review) { + throw new NotFoundException('Review not found'); + } + + await this.reviewRepository.remove(review); + return { message: 'Review deleted successfully' }; + } + + async getProductDetails(productId: string): Promise { + const product = await this.productRepository.findOne({ + where: { id: productId }, + relations: ['reviews'], + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const averageRating = + product.reviews.length > 0 ? product.reviews.reduce((acc, r) => acc + r.rating, 0) / product.reviews.length : 0; + + const recentReviews = product.reviews + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, 5) + .map(r => ({ + rating: r.rating, + review: r.review, + createdBy: r.createdBy, + createdAt: r.createdAt, + })); + + return { + id: product.id, + name: product.name, + description: product.description, + averageRating, + totalReviews: product.reviews.length, + recentReviews, + }; + } } diff --git a/src/modules/products/tests/mocks/deleted-product.mock.ts b/src/modules/products/tests/mocks/deleted-product.mock.ts index 5781f036f..0852993d1 100644 --- a/src/modules/products/tests/mocks/deleted-product.mock.ts +++ b/src/modules/products/tests/mocks/deleted-product.mock.ts @@ -24,4 +24,5 @@ export const deletedProductMock: Product = { org: orgMock, created_at: new Date(), updated_at: new Date(), + reviews: [], }; diff --git a/src/modules/products/tests/mocks/product.mock.ts b/src/modules/products/tests/mocks/product.mock.ts index c6b26ac6c..5a8d000c3 100644 --- a/src/modules/products/tests/mocks/product.mock.ts +++ b/src/modules/products/tests/mocks/product.mock.ts @@ -24,4 +24,5 @@ export const productMock: Product = { cost_price: 10, cart: [], orderItems: [], + reviews: [], }; diff --git a/src/modules/products/tests/products.service.spec.ts b/src/modules/products/tests/products.service.spec.ts index 964c04c83..375a35175 100644 --- a/src/modules/products/tests/products.service.spec.ts +++ b/src/modules/products/tests/products.service.spec.ts @@ -17,6 +17,7 @@ import { mockComment } from './mocks/comment.mock'; import { deletedProductMock } from './mocks/deleted-product.mock'; import { createProductRequestDtoMock } from './mocks/product-request-dto.mock'; import { productMock } from './mocks/product.mock'; +import { Review } from '../entities/review.entity'; describe('ProductsService', () => { let service: ProductsService; @@ -24,6 +25,7 @@ describe('ProductsService', () => { let organisationRepository: Repository; let userRepository: Repository; let commentRepository: Repository; + let reviewRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -58,6 +60,15 @@ describe('ProductsService', () => { save: jest.fn(), }, }, + { + provide: getRepositoryToken(Review), // ✅ Added the missing ReviewRepository correctly + useValue: { + createQueryBuilder: jest.fn(), // Optional — mock these if Review is used in queries + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }, + }, { provide: getRepositoryToken(User), useClass: Repository,