From bc5e38c3aaf9bd5ed896e93363a718b0dcfad700 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 3 May 2024 15:40:27 +0300 Subject: [PATCH 01/32] Add joined at date for searched user --- .../migrations/20240503121928_add_created_at_date/migration.sql | 2 ++ src/user/service/user.service.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/prisma/migrations/20240503121928_add_created_at_date/migration.sql diff --git a/src/prisma/migrations/20240503121928_add_created_at_date/migration.sql b/src/prisma/migrations/20240503121928_add_created_at_date/migration.sql new file mode 100644 index 0000000..bf809d3 --- /dev/null +++ b/src/prisma/migrations/20240503121928_add_created_at_date/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index ef729d0..3332382 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -148,7 +148,7 @@ export class UserService { name: true, joinedAt: true, isEmailVerified: true, - jobTitle: true, + headline: true, profilePictureUrl: true, followings: true, _count: { From ffe6c3a44c3c520252c9ebf99d37ee69b48a8d6b Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 3 May 2024 20:08:03 +0300 Subject: [PATCH 02/32] Update user search query to respect respect http standards --- src/user/controller/user.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index 91f6015..0b51656 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -28,6 +28,7 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiQuery, ApiTags, } from '@nestjs/swagger'; import { userExtraProps, userProperties } from '../../schemas/user.properties'; From 114a08118f34db0ca636590ebef1e263c02c5d01 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 3 May 2024 21:06:24 +0300 Subject: [PATCH 03/32] Correct relations --- .../migrations/20240503180418_inverse_relations/migration.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/prisma/migrations/20240503180418_inverse_relations/migration.sql diff --git a/src/prisma/migrations/20240503180418_inverse_relations/migration.sql b/src/prisma/migrations/20240503180418_inverse_relations/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/src/prisma/migrations/20240503180418_inverse_relations/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file From 0832ac39c6f16dd5341e38645b6faedd38f302da Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 3 May 2024 22:45:03 +0300 Subject: [PATCH 04/32] Check if users are connected when doing an user search --- src/user/service/user.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 3332382..3906da7 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -131,7 +131,7 @@ export class UserService { })); } - async searchUsers(searchTerm?: string) { + async searchUsers(userId: User['id'], searchTerm?: string) { if (!searchTerm) { throw new BadRequestException('Search term is required'); } From 0d875d1181d526951fd3656a000aa8c95308505c Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Thu, 9 May 2024 23:00:16 +0300 Subject: [PATCH 05/32] fix db structure --- .../migrations/20240503121928_add_created_at_date/migration.sql | 2 -- .../migrations/20240503180418_inverse_relations/migration.sql | 1 - 2 files changed, 3 deletions(-) delete mode 100644 src/prisma/migrations/20240503121928_add_created_at_date/migration.sql delete mode 100644 src/prisma/migrations/20240503180418_inverse_relations/migration.sql diff --git a/src/prisma/migrations/20240503121928_add_created_at_date/migration.sql b/src/prisma/migrations/20240503121928_add_created_at_date/migration.sql deleted file mode 100644 index bf809d3..0000000 --- a/src/prisma/migrations/20240503121928_add_created_at_date/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "User" ADD COLUMN "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/src/prisma/migrations/20240503180418_inverse_relations/migration.sql b/src/prisma/migrations/20240503180418_inverse_relations/migration.sql deleted file mode 100644 index af5102c..0000000 --- a/src/prisma/migrations/20240503180418_inverse_relations/migration.sql +++ /dev/null @@ -1 +0,0 @@ --- This is an empty migration. \ No newline at end of file From 229902329056ed5405bc8c4c5d87307cc0b16f17 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Mon, 13 May 2024 15:39:45 +0300 Subject: [PATCH 06/32] Fix: is connection should be true when userId is in the followers --- src/user/service/user.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 3906da7..9da2dfe 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -162,7 +162,7 @@ export class UserService { return users.map(({ _count, followings, ...user }) => ({ connectionsCount: _count.followings, ratingsCount: _count.ratingsReceived, - isConnection: followings.length == 0, + isConnection: followings.length != 0, ...user, })); } From 5c00a65697266930ca7881eee5ec9caa47a33dcf Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 17 May 2024 11:07:43 +0300 Subject: [PATCH 07/32] Do not use mutler for uploading profile pics * Context: React native web sends base64 images that cannot be parsed by mutler --- src/main.ts | 3 +++ src/user/controller/user.controller.ts | 15 +++------------ src/user/service/user.service.ts | 18 +++++++++++++++--- utils/image.ts | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 utils/image.ts diff --git a/src/main.ts b/src/main.ts index 76e2fb5..1c6da3a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app/app.module'; import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { json } from 'express'; function initializeSwagger(app: any) { const config = new DocumentBuilder() @@ -26,6 +27,8 @@ async function bootstrap() { }), ); // initializeS3(); + app.use('/api/user/profile-picture', json({ limit: '10mb' })); + app.use(json({ limit: '100kb' })); initializeSwagger(app); await app.listen(4200); } diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index 0b51656..b03c2d5 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -8,21 +8,19 @@ import { Put, Req, Res, - UploadedFile, UseGuards, - UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '../../decorators/current-user.decorator'; import { UserService } from '../service/user.service'; import { SocialAccountType, User } from '@prisma/client'; import { RatingDto } from '../dto/rating.dto'; import { UpdateUserDto } from '../dto/update-user.dto'; -import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBadRequestResponse, ApiBearerAuth, ApiBody, ApiConsumes, + ApiConflictResponse, ApiCreatedResponse, ApiInternalServerErrorResponse, ApiNotFoundResponse, @@ -37,7 +35,6 @@ import { reviewPropertiesWithComment, } from '../../schemas/review.properties'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Multer } from 'multer'; import { Public } from '../../decorators/public.decorator'; import { Request, Response } from 'express'; import { LinkedInOAuthStrategyFactory } from '../../oauth/factory/linkedin/linkedin-strategy.factory'; @@ -88,9 +85,8 @@ export class UserController { } @Put('/profile-picture') - @UseInterceptors(FileInterceptor('file')) @ApiOperation({ - summary: 'Upload profile picture', + summary: 'Upload profile picture encoded in base64', description: 'Upload a new profile picture', }) @ApiOkResponse({ @@ -103,22 +99,17 @@ export class UserController { @ApiInternalServerErrorResponse({ description: 'Failed to upload profile picture', }) - @ApiConsumes('multipart/form-data') @ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', - format: 'binary', }, }, }, }) - async uploadFile( - @CurrentUser() user: User, - @UploadedFile() file: Express.Multer.File, - ) { + async uploadFile(@CurrentUser() user: User, @Body('file') file: string) { return this.userService.updateProfilePicture(user, file); } diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 9da2dfe..d5cc2bc 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -19,6 +19,7 @@ import { REDIS_CLIENT } from '../../provider/redis.provider'; import { Redis } from 'ioredis'; import { ProfileFetcherDelegator } from '../profile-fetcher/delegator.profile-fetcher'; import { v4 } from 'uuid'; +import { getMimeType } from 'utils/image'; @Injectable() export class UserService { @@ -44,12 +45,23 @@ export class UserService { }); } - async updateProfilePicture(user: User, file: Express.Multer.File) { + async updateProfilePicture(user: User, file: string) { + const type = getMimeType(file); + if (type !== 'image/jpg' && type !== 'image/jpeg' && type !== 'image/png') { + throw new BadRequestException('Only jpg, jpeg and png are accepted'); + } + + const buf = Buffer.from( + file.replace(/^data:image\/\w+;base64,/, ''), + 'base64', + ); + + console.log('here', file, user); const putObjectRequest = new PutObjectCommand({ Bucket: process.env.AWS_S3_BUCKET_NAME, Key: `profile-pictures/${user.id}`, - Body: file.buffer, - ContentType: file.mimetype, + Body: buf, + ContentType: type, }); try { diff --git a/utils/image.ts b/utils/image.ts new file mode 100644 index 0000000..515e8d0 --- /dev/null +++ b/utils/image.ts @@ -0,0 +1,18 @@ +const BASE64_MARKER = ';base64,'; + +export function convertDataURIToBinary(dataURI) { + const base64Index = dataURI.indexOf(BASE64_MARKER) + BASE64_MARKER.length; + const base64 = dataURI.substring(base64Index); + const raw = window.atob(base64); + const rawLength = raw.length; + const array = new Uint8Array(new ArrayBuffer(rawLength)); + + for (let i = 0; i < rawLength; i++) { + array[i] = raw.charCodeAt(i); + } + return array; +} + +export const getMimeType = (file: string) => { + return file.split(/;|:/)[1]; +}; From cb1d5db1bcdb846cef0e3fef07ae39916974ca55 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 17 May 2024 16:33:59 +0300 Subject: [PATCH 08/32] update review properties --- src/schemas/review.properties.ts | 16 ++++++++++++++++ src/user/service/user.service.ts | 11 ++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/schemas/review.properties.ts b/src/schemas/review.properties.ts index 25abfb2..2d325c7 100644 --- a/src/schemas/review.properties.ts +++ b/src/schemas/review.properties.ts @@ -7,6 +7,14 @@ const base = { type: 'string', example: 'https://example.com/profile.jpg', }, + isEmailVerified: { + type: 'boolean', + example: true, + }, + isAnonymous: { + type: 'boolean', + example: true, + }, professionalism: { type: 'number', example: 5, @@ -19,6 +27,14 @@ const base = { type: 'number', example: 5, }, + comment: { + type: 'string', + example: 'Great.', + }, + postedById: { + type: 'string', + example: 1, + }, createdOn: { type: 'string', format: 'date-time', diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index d5cc2bc..7a5c538 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -130,16 +130,25 @@ export class UserService { include: { postedBy: true, }, + orderBy: { + createdAt: 'desc', + }, }); return ratings.map((review) => ({ userName: review.postedBy ? review.postedBy.name : 'Anonymous', + isEmailVerified: review.postedBy + ? review.postedBy.isEmailVerified + : false, + isAnonymous: !review.postedBy, profilePictureUrl: review.postedBy?.profilePictureUrl, professionalism: review.professionalism, reliability: review.reliability, communication: review.communication, + comment: review.comment, createdOn: review.createdAt.toISOString(), - comment: !self ? review.comment : undefined, + postedById: + review.postedBy && !review.anonymous ? review.postedById : undefined, })); } From 8a0ad34095cfe7ea131c2595ae5ed659947464a6 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Sat, 18 May 2024 16:24:20 +0300 Subject: [PATCH 09/32] add get user endpoint --- src/user/controller/user.controller.ts | 19 +++++++ src/user/service/user.service.ts | 70 ++++++++++++++++++++------ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index b03c2d5..60a235b 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -65,6 +65,25 @@ export class UserController { return this.userService.getSelf(user); } + @Get('/:userId') + @ApiOperation({ + summary: 'Get another user', + description: 'Get the currently logged in user', + }) + @ApiOkResponse({ + description: 'User found', + schema: { + type: 'object', + properties: { ...userExtraProps, ...userProperties }, + }, + }) + async getUser( + @CurrentUser() user: User, + @Param('userId') userId: User['id'], + ) { + return this.userService.getUser(user.id, userId); + } + @Put() @ApiOperation({ summary: 'Update current user', diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 7a5c538..6050d52 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -152,6 +152,30 @@ export class UserService { })); } + //user props returned to backend + private selectUserWithExtraProps(currentUserId: User['id']) { + return { + id: true, + email: true, + name: true, + joinedAt: true, + isEmailVerified: true, + jobTitle: true, + profilePictureUrl: true, + followings: { + where: { + followerId: currentUserId, + }, + }, + _count: { + select: { + followings: true, + ratingsReceived: true, + }, + }, + }; + } + async searchUsers(userId: User['id'], searchTerm?: string) { if (!searchTerm) { throw new BadRequestException('Search term is required'); @@ -163,23 +187,9 @@ export class UserService { { name: { contains: searchTerm } }, ], }, - select: { - id: true, - email: true, - name: true, - joinedAt: true, - isEmailVerified: true, - headline: true, - profilePictureUrl: true, - followings: true, - _count: { - select: { - followings: true, - ratingsReceived: true, - }, - }, - }, + select: this.selectUserWithExtraProps(userId), }); + return users.map(({ _count, followings, ...user }) => ({ connectionsCount: _count.followings, ratingsCount: _count.ratingsReceived, @@ -312,6 +322,34 @@ export class UserService { return avgRatings; } + private async findUserById(id: string) { + return await this.prisma.user.findUnique({ where: { id } }); + } + + public async getUser(currentUserId: User['id'], id: User['id']) { + const userWithCounts = await this.prisma.user.findUnique({ + where: { id }, + select: this.selectUserWithExtraProps(currentUserId), + }); + + const { _count, followings, ...user } = userWithCounts; + + return { + connectionsCount: _count.followings, + ratingsCount: _count.ratingsReceived, + isConnection: followings.length != 0, + ...user, + }; + } + + private async findUserByEmail(email: string) { + return await this.prisma.user.findUnique({ + where: { + email, + }, + }); + } + private async calculateAvgRating(userId: User['id']) { const result = await this.prisma.rating.aggregate({ where: { postedToId: userId }, From d2476fe51aa10aad6dcf7f4c42e232ca2bce167a Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Sat, 18 May 2024 17:57:18 +0300 Subject: [PATCH 10/32] use headline insted of jobtitle --- src/user/service/user.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 6050d52..eb5f667 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -160,7 +160,7 @@ export class UserService { name: true, joinedAt: true, isEmailVerified: true, - jobTitle: true, + headline: true, profilePictureUrl: true, followings: { where: { From f4bd076addde5742eb86402aa6c8f10811286da4 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Mon, 20 May 2024 13:52:31 +0300 Subject: [PATCH 11/32] remove console logs --- src/user/service/user.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index eb5f667..f61b42c 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -47,6 +47,7 @@ export class UserService { async updateProfilePicture(user: User, file: string) { const type = getMimeType(file); + this.logger.warn(type, file); if (type !== 'image/jpg' && type !== 'image/jpeg' && type !== 'image/png') { throw new BadRequestException('Only jpg, jpeg and png are accepted'); } @@ -56,7 +57,6 @@ export class UserService { 'base64', ); - console.log('here', file, user); const putObjectRequest = new PutObjectCommand({ Bucket: process.env.AWS_S3_BUCKET_NAME, Key: `profile-pictures/${user.id}`, @@ -75,7 +75,6 @@ export class UserService { }, }); } catch (err) { - this.logger.error(err); throw new InternalServerErrorException( 'Failed to upload profile picture', ); From 4929924d22ce393a4e554da805043c6b23ba4f32 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Mon, 20 May 2024 17:36:12 +0300 Subject: [PATCH 12/32] remove console log --- src/user/service/user.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index f61b42c..3e67668 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -47,7 +47,6 @@ export class UserService { async updateProfilePicture(user: User, file: string) { const type = getMimeType(file); - this.logger.warn(type, file); if (type !== 'image/jpg' && type !== 'image/jpeg' && type !== 'image/png') { throw new BadRequestException('Only jpg, jpeg and png are accepted'); } From d20e21024a44e38caede26d07af25e13ec392486 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Tue, 21 May 2024 11:30:12 +0300 Subject: [PATCH 13/32] Move reviwes in dedicated controller. * Rename ratings to reviews * Use ratings only for professionalism/communication/reliability * Create reviews controller and service * Remove route with */self. --- .../migration.sql | 35 +++++ src/prisma/schema.prisma | 12 +- .../DTO/create-review.dto.ts} | 17 ++- src/reviews/DTO/rating.dto.ts | 5 + src/reviews/DTO/reviews.dto.ts | 35 +++++ src/reviews/reviews.controller.spec.ts | 18 +++ src/reviews/reviews.controller.ts | 41 +++++ src/reviews/reviews.module.ts | 9 ++ src/reviews/reviews.service.spec.ts | 18 +++ src/reviews/reviews.service.ts | 144 ++++++++++++++++++ src/user/controller/user.controller.ts | 131 +--------------- src/user/service/user.service.ts | 118 -------------- 12 files changed, 330 insertions(+), 253 deletions(-) create mode 100644 src/prisma/migrations/20240520150105_ratings_to_reviews/migration.sql rename src/{user/dto/rating.dto.ts => reviews/DTO/create-review.dto.ts} (83%) create mode 100644 src/reviews/DTO/rating.dto.ts create mode 100644 src/reviews/DTO/reviews.dto.ts create mode 100644 src/reviews/reviews.controller.spec.ts create mode 100644 src/reviews/reviews.controller.ts create mode 100644 src/reviews/reviews.module.ts create mode 100644 src/reviews/reviews.service.spec.ts create mode 100644 src/reviews/reviews.service.ts diff --git a/src/prisma/migrations/20240520150105_ratings_to_reviews/migration.sql b/src/prisma/migrations/20240520150105_ratings_to_reviews/migration.sql new file mode 100644 index 0000000..c6d93bf --- /dev/null +++ b/src/prisma/migrations/20240520150105_ratings_to_reviews/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - You are about to drop the `Rating` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Rating" DROP CONSTRAINT "Rating_postedById_fkey"; + +-- DropForeignKey +ALTER TABLE "Rating" DROP CONSTRAINT "Rating_postedToId_fkey"; + +-- DropTable +DROP TABLE "Rating"; + +-- CreateTable +CREATE TABLE "Review" ( + "id" SERIAL NOT NULL, + "postedToId" TEXT NOT NULL, + "postedById" TEXT, + "professionalism" INTEGER NOT NULL DEFAULT 0, + "reliability" INTEGER NOT NULL DEFAULT 0, + "communication" INTEGER NOT NULL DEFAULT 0, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "anonymous" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Review_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_postedToId_fkey" FOREIGN KEY ("postedToId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_postedById_fkey" FOREIGN KEY ("postedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 7ab64bd..6b46dae 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -26,7 +26,7 @@ enum SocialAccountType { model User { id String @id @default(cuid()) - email String? @unique + email String @unique name String? profilePictureUrl String? socialAccounts SocialAccount[] @@ -36,8 +36,8 @@ model User { jobTitle String? followers Connection[] @relation("followsTheUser") followings Connection[] @relation("followedByUser") - ratingsPosted Rating[] @relation("ratingsGivenToOtherUsers") - ratingsReceived Rating[] @relation("ratingsRecievedFromOtherUsers") + reviewsPosted Review[] @relation("reviewsGivenToOtherUsers") + reviewsReceived Review[] @relation("reviewsRecievedFromOtherUsers") joinedAt DateTime @default(now()) } @@ -63,13 +63,13 @@ model SocialAccount { @@index([userId, platform, profileUrl]) } -model Rating { +model Review { id Int @id @default(autoincrement()) // Person who is being rated - postedTo User @relation(fields: [postedToId], references: [id], onDelete: Cascade, onUpdate: Cascade, name: "ratingsRecievedFromOtherUsers") + postedTo User @relation(fields: [postedToId], references: [id], onDelete: Cascade, onUpdate: Cascade, name: "reviewsRecievedFromOtherUsers") postedToId String // Person who is rating - postedBy User? @relation(fields: [postedById], references: [id], onDelete: SetNull, onUpdate: Cascade, name: "ratingsGivenToOtherUsers") + postedBy User? @relation(fields: [postedById], references: [id], onDelete: SetNull, onUpdate: Cascade, name: "reviewsGivenToOtherUsers") postedById String? professionalism Int @default(0) reliability Int @default(0) diff --git a/src/user/dto/rating.dto.ts b/src/reviews/DTO/create-review.dto.ts similarity index 83% rename from src/user/dto/rating.dto.ts rename to src/reviews/DTO/create-review.dto.ts index 2968f09..fc40081 100644 --- a/src/user/dto/rating.dto.ts +++ b/src/reviews/DTO/create-review.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { IsBoolean, IsNumber, @@ -6,9 +7,10 @@ import { IsString, Max, Min, + ValidateNested, } from 'class-validator'; -export class RatingDto { +export class CreateReviewDto { @IsNumber({}, { message: 'Quality must be a number' }) @Min(1, { message: 'Rating must be at least 1' }) @Max(5, { message: 'Rating must be at most 5' }) @@ -68,3 +70,16 @@ export class RatingDto { }) anonymous?: boolean; } + +export class CreateReviewBodyDTO { + @IsString() + postedToId: string; + + @Type(() => CreateReviewDto) + @ValidateNested() + @ApiProperty({ + name: 'review', + description: 'Review data', + }) + review: CreateReviewDto; +} diff --git a/src/reviews/DTO/rating.dto.ts b/src/reviews/DTO/rating.dto.ts new file mode 100644 index 0000000..5d853dc --- /dev/null +++ b/src/reviews/DTO/rating.dto.ts @@ -0,0 +1,5 @@ +export class RatingDto { + professionalism: number; + communication: number; + reliability: number; +} diff --git a/src/reviews/DTO/reviews.dto.ts b/src/reviews/DTO/reviews.dto.ts new file mode 100644 index 0000000..5d75388 --- /dev/null +++ b/src/reviews/DTO/reviews.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDate, ValidateNested } from 'class-validator'; + +export class PostedByDTO { + name?: string; + + profilePictureUrl?: string; + isEmailVerified?: boolean; + id: string; +} + +export class ReviewDto { + id: number; + comment: string; + professionalism: number; + reliability: number; + communication: number; + @Type(() => Date) + @IsDate() + createdAt: Date; + + postedToId: string; + + @ApiProperty({ + description: 'True if the review was posted by the current user.', + }) + isOwnReview: boolean; + + isAnonymous: boolean; + + @Type(() => PostedByDTO) + @ValidateNested() + postedBy?: PostedByDTO; +} diff --git a/src/reviews/reviews.controller.spec.ts b/src/reviews/reviews.controller.spec.ts new file mode 100644 index 0000000..e1cd836 --- /dev/null +++ b/src/reviews/reviews.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewsController } from './reviews.controller'; + +describe('ReviewsController', () => { + let controller: ReviewsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReviewsController], + }).compile(); + + controller = module.get(ReviewsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts new file mode 100644 index 0000000..8f8e9ba --- /dev/null +++ b/src/reviews/reviews.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ReviewsService } from './reviews.service'; +import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ReviewDto } from './DTO/reviews.dto'; +import { CurrentUser } from 'src/decorators/current-user.decorator'; +import { User } from '@prisma/client'; +import { CreateReviewBodyDTO } from './DTO/create-review.dto'; +import { RatingDto } from './DTO/rating.dto'; + +@Controller('reviews') +@ApiBearerAuth() +export class ReviewsController { + constructor(private readonly reviewsService: ReviewsService) {} + + @Get('/:userId') + @ApiOperation({ summary: `Get reviews for user with ID userID` }) + async getReviews( + @CurrentUser() user: User, + @Param('userId') postedToId: string, + ): Promise> { + return this.reviewsService.getUserReviews(user, postedToId); + } + + @Post() + @ApiOperation({ summary: 'Create a reviews' }) + async createReview( + @CurrentUser() user: User, + @Body() { postedToId, review }: CreateReviewBodyDTO, + ): Promise { + return this.reviewsService.createReview(user, postedToId, review); + } + + @Get('avg-rating/:userId') + @ApiOperation({ + summary: 'Get user average rating', + description: 'Get average rating of another user', + }) + async getUserRatings(@Param('userId') userId: string): Promise { + return this.reviewsService.getAvgUserRatings(userId); + } +} diff --git a/src/reviews/reviews.module.ts b/src/reviews/reviews.module.ts new file mode 100644 index 0000000..a983792 --- /dev/null +++ b/src/reviews/reviews.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ReviewsService } from './reviews.service'; +import { ReviewsController } from './reviews.controller'; + +@Module({ + providers: [ReviewsService], + controllers: [ReviewsController], +}) +export class ReviewsModule {} diff --git a/src/reviews/reviews.service.spec.ts b/src/reviews/reviews.service.spec.ts new file mode 100644 index 0000000..2f2b32e --- /dev/null +++ b/src/reviews/reviews.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewsService } from './reviews.service'; + +describe('ReviewsService', () => { + let service: ReviewsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReviewsService], + }).compile(); + + service = module.get(ReviewsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts new file mode 100644 index 0000000..ee71edf --- /dev/null +++ b/src/reviews/reviews.service.ts @@ -0,0 +1,144 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { User, Review } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { ReviewDto } from './DTO/reviews.dto'; +import { REDIS_CLIENT } from 'src/provider/redis.provider'; +import Redis from 'ioredis'; +import { CreateReviewDto } from './DTO/create-review.dto'; +import { RatingDto } from './DTO/rating.dto'; + +@Injectable() +export class ReviewsService { + constructor( + private readonly prisma: PrismaService, + @Inject(REDIS_CLIENT) private cache: Redis, + ) {} + + private transformReview( + review: Review & { postedBy: User }, + currentUserId: User['id'], + ): ReviewDto { + return { + postedBy: review.anonymous + ? { + id: review.postedBy.id, + isEmailVerified: review.postedBy.isEmailVerified, + profilePictureUrl: review.postedBy.profilePictureUrl, + name: review.postedBy.name, + } + : undefined, + isAnonymous: !review.postedBy, + professionalism: review.professionalism, + reliability: review.reliability, + communication: review.communication, + comment: review.comment, + createdAt: review.createdAt, + isOwnReview: review.postedById == currentUserId, + postedToId: review.postedToId, + id: review.id, + }; + } + + private async calculateAvgRating(userId: User['id']): Promise { + const result = await this.prisma.review.aggregate({ + where: { postedToId: userId }, + _avg: { + professionalism: true, + reliability: true, + communication: true, + }, + }); + + return { + professionalism: result._avg.professionalism ?? 0, + reliability: result._avg.reliability ?? 0, + communication: result._avg.communication ?? 0, + }; + } + + async getUserReviews(user: User, postedToId: User['id']) { + const ratings = await this.prisma.review.findMany({ + where: { postedToId: postedToId }, + include: { + postedBy: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return ratings.map((review) => this.transformReview(review, user.id)); + } + + async createReview( + user: User, + postedToId: User['id'], + ratingDto: CreateReviewDto, + ) { + const ratedUser = await this.prisma.user.findUnique({ + where: { id: postedToId }, + }); + + // Check if the user exists + if (!ratedUser) { + throw new NotFoundException('User not found'); + } + + // Check if the user is trying to rate himself + if (user.id === postedToId) { + throw new BadRequestException('You cannot rate yourself'); + } + + ratingDto.anonymous = ratingDto.anonymous ?? false; + + // Rate the user + const review = await this.prisma.review.create({ + data: { + postedToId: postedToId, + postedById: ratingDto.anonymous ? null : user.id, + professionalism: ratingDto.professionalism, + reliability: ratingDto.reliability, + communication: ratingDto.communication, + comment: ratingDto.comment, + anonymous: ratingDto.anonymous, + }, + include: { + postedBy: true, + }, + }); + + // Update the cache + const avgRatings = await this.calculateAvgRating(postedToId); + await this.cache.set( + `avg-ratings-${postedToId}`, + JSON.stringify(avgRatings), + ); + + return this.transformReview(review, user.id); + } + + async getAvgUserRatings(userId: User['id']) { + // Check the cache first + const cachedRatings = JSON.parse( + await this.cache.get(`avg-ratings-${userId}`), + ); + + // If present, return the cached ratings + if (cachedRatings) { + return cachedRatings; + } + + // If not, calculate the average ratings + const avgRatings = await this.calculateAvgRating(userId); + + // Cache the ratings for 24 hours + await this.cache.set(`avg-ratings-${userId}`, JSON.stringify(avgRatings)); + + return avgRatings; + } +} diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index 60a235b..0006cda 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -14,6 +14,7 @@ import { CurrentUser } from '../../decorators/current-user.decorator'; import { UserService } from '../service/user.service'; import { SocialAccountType, User } from '@prisma/client'; import { RatingDto } from '../dto/rating.dto'; +import { User } from '@prisma/client'; import { UpdateUserDto } from '../dto/update-user.dto'; import { ApiBadRequestResponse, @@ -23,18 +24,13 @@ import { ApiConflictResponse, ApiCreatedResponse, ApiInternalServerErrorResponse, - ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiQuery, ApiTags, } from '@nestjs/swagger'; import { userExtraProps, userProperties } from '../../schemas/user.properties'; -import { - reviewProperties, - reviewPropertiesWithComment, -} from '../../schemas/review.properties'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars + import { Public } from '../../decorators/public.decorator'; import { Request, Response } from 'express'; import { LinkedInOAuthStrategyFactory } from '../../oauth/factory/linkedin/linkedin-strategy.factory'; @@ -132,82 +128,6 @@ export class UserController { return this.userService.updateProfilePicture(user, file); } - @Post('rate/:userId') - @ApiOperation({ - summary: 'Rate user', - description: 'Rate another user', - }) - @ApiBadRequestResponse({ - description: 'Invalid rating', - }) - @ApiNotFoundResponse({ - description: 'User not found', - }) - @ApiCreatedResponse({ - description: 'User rated successfully', - schema: { - type: 'object', - properties: { - id: { type: 'number' }, - postedToId: { type: 'string' }, - raterUserId: { type: 'string' }, - professionalism: { type: 'number' }, - reliability: { type: 'number' }, - communication: { type: 'number' }, - comment: { type: 'string' }, - anonymous: { type: 'boolean' }, - }, - }, - }) - async rateUser( - @CurrentUser() user: User, - @Param('userId') postedToId: string, - @Body() rating: RatingDto, - ) { - return this.userService.rateUser(user, postedToId, rating); - } - - @Get('ratings/self') - @ApiOperation({ - summary: 'Get self reviews', - description: 'Get reviews of the currently logged in user', - }) - @ApiOkResponse({ - description: 'Self reviews found', - schema: { - type: 'array', - items: { - type: 'object', - properties: reviewProperties, - }, - }, - }) - async getSelfReviews(@CurrentUser() user: User) { - return this.userService.getUserRatings(user, true); - } - - @Get('ratings/:userId') - @ApiOperation({ - summary: 'Get user reviews', - description: 'Get reviews of another user', - }) - @ApiOkResponse({ - description: 'User reviews found', - schema: { - type: 'array', - items: { - type: 'object', - properties: reviewPropertiesWithComment, - }, - }, - }) - async getUserReviews( - @CurrentUser() user: User, - @Param('userId') revieweeUserId: string, - ) { - return this.userService.getUserRatings(user, false, revieweeUserId); - } - @Get('link-social/linkedin/callback') @UseGuards(AuthGuard('linkedin')) @Public() @@ -229,52 +149,7 @@ export class UserController { res.status(302).redirect('/api/user/link-social/linkedin/callback'); } - @Get('avg-rating/self') - @ApiOperation({ - summary: 'Get self average rating', - description: 'Get average rating of the currently logged in user', - }) - @ApiOkResponse({ - description: 'Average Rating calculated', - schema: { - type: 'object', - properties: { - professionalism: { type: 'number' }, - reliability: { type: 'number' }, - communication: { type: 'number' }, - overall: { type: 'number' }, - }, - }, - }) - async getSelfRatings(@CurrentUser() user: User) { - return this.userService.getAvgUserRatings(user, true); - } - - @Get('avg-rating/:userId') - @ApiOperation({ - summary: 'Get user average rating', - description: 'Get average rating of another user', - }) - @ApiOkResponse({ - description: 'Average Rating calculated', - schema: { - type: 'object', - properties: { - professionalism: { type: 'number' }, - reliability: { type: 'number' }, - communication: { type: 'number' }, - overall: { type: 'number' }, - }, - }, - }) - async getUserRatings( - @CurrentUser() user: User, - @Param('userId') userId: string, - ) { - return this.userService.getAvgUserRatings(user, false, userId); - } - - @Get('search/:query') + @Get('search') @ApiOperation({ summary: 'Search users', description: 'Search for users', diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 3e67668..d707014 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -8,13 +8,9 @@ import { } from '@nestjs/common'; import { AuthType, SocialAccountType, User } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; -import { RatingDto } from '../dto/rating.dto'; import { UpdateUserDto } from '../dto/update-user.dto'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { S3_CLIENT } from '../../provider/s3.provider'; -// This is needed for file upload to work. Don't remove this -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Multer } from 'multer'; import { REDIS_CLIENT } from '../../provider/redis.provider'; import { Redis } from 'ioredis'; import { ProfileFetcherDelegator } from '../profile-fetcher/delegator.profile-fetcher'; @@ -80,76 +76,6 @@ export class UserService { } } - async rateUser(user: User, postedToId: User['id'], ratingDto: RatingDto) { - const ratedUser = await this.prisma.user.findUnique({ - where: { id: postedToId }, - }); - - // Check if the user exists - if (!ratedUser) { - throw new NotFoundException('User not found'); - } - - // Check if the user is trying to rate himself - if (user.id === postedToId) { - throw new BadRequestException('You cannot rate yourself'); - } - - ratingDto.anonymous = ratingDto.anonymous ?? false; - - // Rate the user - const rating = await this.prisma.rating.create({ - data: { - postedToId: postedToId, - postedById: ratingDto.anonymous ? null : user.id, - professionalism: ratingDto.professionalism, - reliability: ratingDto.reliability, - communication: ratingDto.communication, - comment: ratingDto.comment, - anonymous: ratingDto.anonymous, - }, - }); - - // Update the cache - const avgRatings = await this.calculateAvgRating(postedToId); - await this.cache.set( - `avg-ratings-${postedToId}`, - JSON.stringify(avgRatings), - ); - - return rating; - } - - async getUserRatings(user: User, self: boolean, revieweeUserId?: User['id']) { - if (self) revieweeUserId = user.id; - - const ratings = await this.prisma.rating.findMany({ - where: { postedToId: revieweeUserId }, - include: { - postedBy: true, - }, - orderBy: { - createdAt: 'desc', - }, - }); - - return ratings.map((review) => ({ - userName: review.postedBy ? review.postedBy.name : 'Anonymous', - isEmailVerified: review.postedBy - ? review.postedBy.isEmailVerified - : false, - isAnonymous: !review.postedBy, - profilePictureUrl: review.postedBy?.profilePictureUrl, - professionalism: review.professionalism, - reliability: review.reliability, - communication: review.communication, - comment: review.comment, - createdOn: review.createdAt.toISOString(), - postedById: - review.postedBy && !review.anonymous ? review.postedById : undefined, - })); - } - //user props returned to backend private selectUserWithExtraProps(currentUserId: User['id']) { return { @@ -298,28 +224,6 @@ export class UserService { return newUser; } - async getAvgUserRatings(user: User, self: boolean, userId?: User['id']) { - if (self) userId = user.id; - - // Check the cache first - const cachedRatings = JSON.parse( - await this.cache.get(`avg-ratings-${userId}`), - ); - - // If present, return the cached ratings - if (cachedRatings) { - return cachedRatings; - } - - // If not, calculate the average ratings - const avgRatings = await this.calculateAvgRating(userId); - - // Cache the ratings for 24 hours - await this.cache.set(`avg-ratings-${userId}`, JSON.stringify(avgRatings)); - - return avgRatings; - } - private async findUserById(id: string) { return await this.prisma.user.findUnique({ where: { id } }); } @@ -347,26 +251,4 @@ export class UserService { }, }); } - - private async calculateAvgRating(userId: User['id']) { - const result = await this.prisma.rating.aggregate({ - where: { postedToId: userId }, - _avg: { - professionalism: true, - reliability: true, - communication: true, - }, - }); - - return { - professionalism: result._avg.professionalism ?? 0, - reliability: result._avg.reliability ?? 0, - communication: result._avg.communication ?? 0, - overall: - (result._avg.professionalism + - result._avg.reliability + - result._avg.communication) / - 3, - }; - } } From 618327cfcf35514ad6e96ae20b84860d52ea3272 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Tue, 21 May 2024 11:43:28 +0300 Subject: [PATCH 14/32] enable swagger plugin --- nest-cli.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nest-cli.json b/nest-cli.json index f9aa683..5c20f21 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,15 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "plugins":[ + { + "name": "@nestjs/swagger", + "options": { + "classValidatorShim": false, + "introspectComments": true + } + } + ] } } From edd1887be364d704c8568aa0238a73eb4a947464 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Tue, 21 May 2024 13:04:48 +0300 Subject: [PATCH 15/32] review db structure changes: * change id from int to string * add state * add favorite review model --- .../migration.sql | 24 +++++++++ .../migration.sql | 21 ++++++++ src/prisma/schema.prisma | 51 +++++++++++++------ 3 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql create mode 100644 src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql diff --git a/src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql b/src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql new file mode 100644 index 0000000..d903424 --- /dev/null +++ b/src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql @@ -0,0 +1,24 @@ +-- CreateEnum +CREATE TYPE "ReviewState" AS ENUM ('PENDING', 'BLOCKED', 'APPROVED'); + +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "state" "ReviewState" NOT NULL DEFAULT 'PENDING', +ALTER COLUMN "anonymous" SET DEFAULT true; + +-- CreateTable +CREATE TABLE "FavoriteReview" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "reviewId" INTEGER NOT NULL, + + CONSTRAINT "FavoriteReview_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "FavoriteReview_userId_reviewId_key" ON "FavoriteReview"("userId", "reviewId"); + +-- AddForeignKey +ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql b/src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql new file mode 100644 index 0000000..bfc0c95 --- /dev/null +++ b/src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - The primary key for the `Review` table will be changed. If it partially fails, the table could be left without primary key constraint. + +*/ +-- DropForeignKey +ALTER TABLE "FavoriteReview" DROP CONSTRAINT "FavoriteReview_reviewId_fkey"; + +-- AlterTable +ALTER TABLE "FavoriteReview" ALTER COLUMN "reviewId" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "Review" DROP CONSTRAINT "Review_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "Review_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "Review_id_seq"; + +-- AddForeignKey +ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 6b46dae..e923316 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -25,20 +25,21 @@ enum SocialAccountType { } model User { - id String @id @default(cuid()) - email String @unique + id String @id @default(cuid()) + email String @unique name String? profilePictureUrl String? socialAccounts SocialAccount[] authType AuthType - isEmailVerified Boolean @default(false) + isEmailVerified Boolean @default(false) headline String? jobTitle String? - followers Connection[] @relation("followsTheUser") - followings Connection[] @relation("followedByUser") - reviewsPosted Review[] @relation("reviewsGivenToOtherUsers") - reviewsReceived Review[] @relation("reviewsRecievedFromOtherUsers") - joinedAt DateTime @default(now()) + followers Connection[] @relation("followsTheUser") + followings Connection[] @relation("followedByUser") + reviewsPosted Review[] @relation("reviewsGivenToOtherUsers") + reviewsReceived Review[] @relation("reviewsRecievedFromOtherUsers") + joinedAt DateTime @default(now()) + favoriteReviews FavoriteReview[] @relation("userFavoriteReviews") } model Connection { @@ -63,20 +64,38 @@ model SocialAccount { @@index([userId, platform, profileUrl]) } +enum ReviewState { + PENDING + BLOCKED + APPROVED +} + model Review { - id Int @id @default(autoincrement()) + id String @id @default(cuid()) // Person who is being rated - postedTo User @relation(fields: [postedToId], references: [id], onDelete: Cascade, onUpdate: Cascade, name: "reviewsRecievedFromOtherUsers") + postedTo User @relation(fields: [postedToId], references: [id], onDelete: Cascade, onUpdate: Cascade, name: "reviewsRecievedFromOtherUsers") postedToId String // Person who is rating - postedBy User? @relation(fields: [postedById], references: [id], onDelete: SetNull, onUpdate: Cascade, name: "reviewsGivenToOtherUsers") + postedBy User? @relation(fields: [postedById], references: [id], onDelete: SetNull, onUpdate: Cascade, name: "reviewsGivenToOtherUsers") postedById String? - professionalism Int @default(0) - reliability Int @default(0) - communication Int @default(0) + professionalism Int @default(0) + reliability Int @default(0) + communication Int @default(0) comment String? - createdAt DateTime @default(now()) - anonymous Boolean @default(false) + createdAt DateTime @default(now()) + anonymous Boolean @default(true) + state ReviewState @default(PENDING) + favoriteBy FavoriteReview[] @relation("favoriteReview") +} + +model FavoriteReview { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade, name: "userFavoriteReviews") + reviewId String + review Review @relation(fields: [reviewId], references: [id], onDelete: Cascade, onUpdate: Cascade, name: "favoriteReview") + + @@unique([userId, reviewId]) } model VerificationCode { From f49e0bee1a6e518e760a7c25ab3b61056b6f1ab4 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Tue, 21 May 2024 13:39:53 +0300 Subject: [PATCH 16/32] Add ability to like/unlike reviews and review state --- src/app/app.module.ts | 2 + .../migration.sql | 1 + src/prisma/schema.prisma | 2 +- src/reviews/DTO/create-review.dto.ts | 6 ++ src/reviews/DTO/reviews.dto.ts | 10 ++- src/reviews/reviews.controller.ts | 38 +++++++++- src/reviews/reviews.service.ts | 72 ++++++++++++++++--- 7 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ea4e5d4..a4fe07f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -9,6 +9,7 @@ import { AuthGuard } from '../auth/guard/auth/auth.guard'; import { PrismaModule } from '../prisma/prisma.module'; import { MailModule } from '../mail/mail.module'; import { ProviderModule } from '../provider/provider.module'; +import { ReviewsModule } from 'src/reviews/reviews.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { ProviderModule } from '../provider/provider.module'; PrismaModule, MailModule, ProviderModule, + ReviewsModule, ], controllers: [AppController], providers: [ diff --git a/src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql b/src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index e923316..9192e04 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -85,7 +85,7 @@ model Review { createdAt DateTime @default(now()) anonymous Boolean @default(true) state ReviewState @default(PENDING) - favoriteBy FavoriteReview[] @relation("favoriteReview") + favorites FavoriteReview[] @relation("favoriteReview") } model FavoriteReview { diff --git a/src/reviews/DTO/create-review.dto.ts b/src/reviews/DTO/create-review.dto.ts index fc40081..d1590ef 100644 --- a/src/reviews/DTO/create-review.dto.ts +++ b/src/reviews/DTO/create-review.dto.ts @@ -73,6 +73,12 @@ export class CreateReviewDto { export class CreateReviewBodyDTO { @IsString() + @ApiProperty({ + name: 'postedToId', + description: 'The Id of the reviewed user.', + example: 'asdjas20', + required: true, + }) postedToId: string; @Type(() => CreateReviewDto) diff --git a/src/reviews/DTO/reviews.dto.ts b/src/reviews/DTO/reviews.dto.ts index 5d75388..77af469 100644 --- a/src/reviews/DTO/reviews.dto.ts +++ b/src/reviews/DTO/reviews.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ReviewState } from '@prisma/client'; import { Type } from 'class-transformer'; -import { IsDate, ValidateNested } from 'class-validator'; +import { IsDate, IsEnum, IsOptional, ValidateNested } from 'class-validator'; export class PostedByDTO { name?: string; @@ -11,7 +12,7 @@ export class PostedByDTO { } export class ReviewDto { - id: number; + id: string; comment: string; professionalism: number; reliability: number; @@ -28,8 +29,13 @@ export class ReviewDto { isOwnReview: boolean; isAnonymous: boolean; + isFavorite: boolean; @Type(() => PostedByDTO) + @IsOptional() @ValidateNested() postedBy?: PostedByDTO; + + @IsEnum(ReviewState) + state: ReviewState; } diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index 8f8e9ba..e7681eb 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -1,6 +1,14 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Logger, + Param, + Post, +} from '@nestjs/common'; import { ReviewsService } from './reviews.service'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ReviewDto } from './DTO/reviews.dto'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { User } from '@prisma/client'; @@ -9,7 +17,9 @@ import { RatingDto } from './DTO/rating.dto'; @Controller('reviews') @ApiBearerAuth() +@ApiTags('Reviews controller') export class ReviewsController { + private logger: Logger; constructor(private readonly reviewsService: ReviewsService) {} @Get('/:userId') @@ -22,7 +32,7 @@ export class ReviewsController { } @Post() - @ApiOperation({ summary: 'Create a reviews' }) + @ApiOperation({ summary: 'Create a review.' }) async createReview( @CurrentUser() user: User, @Body() { postedToId, review }: CreateReviewBodyDTO, @@ -38,4 +48,26 @@ export class ReviewsController { async getUserRatings(@Param('userId') userId: string): Promise { return this.reviewsService.getAvgUserRatings(userId); } + + /** + * Like a review + */ + @Post('/:reviewId/like') + async likeReview( + @CurrentUser() user: User, + @Param('reviewId') reviewId: string, + ): Promise { + return this.reviewsService.likeReview(user, reviewId); + } + + /** + * Unlike a review + */ + @Delete('/:reviewId/like') + async unlikeReview( + @CurrentUser() user: User, + @Param('reviewId') reviewId: string, + ): Promise { + return this.reviewsService.unlikeReview(user, reviewId); + } } diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index ee71edf..651f4fc 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { User, Review } from '@prisma/client'; +import { User, Review, FavoriteReview } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { ReviewDto } from './DTO/reviews.dto'; import { REDIS_CLIENT } from 'src/provider/redis.provider'; @@ -19,12 +19,13 @@ export class ReviewsService { @Inject(REDIS_CLIENT) private cache: Redis, ) {} + // transform a review from the DB to ReviewDTO (as expected by the API) private transformReview( - review: Review & { postedBy: User }, + review: Review & { postedBy: User } & { favorites: FavoriteReview[] }, currentUserId: User['id'], ): ReviewDto { return { - postedBy: review.anonymous + postedBy: !review.anonymous ? { id: review.postedBy.id, isEmailVerified: review.postedBy.isEmailVerified, @@ -41,6 +42,20 @@ export class ReviewsService { isOwnReview: review.postedById == currentUserId, postedToId: review.postedToId, id: review.id, + isFavorite: !!review.favorites.find((f) => f.userId === currentUserId), + state: review.state, + }; + } + + // properties to include with the review. Based on the userId to calculate if review is favorite by review. + private includeWithReview(currentUserId: User['id']) { + return { + postedBy: true, + favorites: { + where: { + userId: currentUserId, + }, + }, }; } @@ -64,9 +79,7 @@ export class ReviewsService { async getUserReviews(user: User, postedToId: User['id']) { const ratings = await this.prisma.review.findMany({ where: { postedToId: postedToId }, - include: { - postedBy: true, - }, + include: this.includeWithReview(user.id), orderBy: { createdAt: 'desc', }, @@ -107,9 +120,7 @@ export class ReviewsService { comment: ratingDto.comment, anonymous: ratingDto.anonymous, }, - include: { - postedBy: true, - }, + include: this.includeWithReview(user.id), }); // Update the cache @@ -141,4 +152,47 @@ export class ReviewsService { return avgRatings; } + + async getReview(userId: User['id'], reviewId: Review['id']) { + return this.prisma.review.findUnique({ + where: { + id: reviewId, + }, + include: this.includeWithReview(userId), + }); + } + + async likeReview(user: User, reviewId: Review['id']): Promise { + this.prisma.favoriteReview.upsert({ + where: { + userId_reviewId: { + userId: user.id, + reviewId: reviewId, + }, + }, + update: {}, + create: { + userId: user.id, + reviewId: reviewId, + }, + }); + const review = await this.getReview(user.id, reviewId); + + return this.transformReview(review, user.id); + } + + async unlikeReview(user: User, reviewId: Review['id']): Promise { + await this.prisma.favoriteReview.delete({ + where: { + userId_reviewId: { + userId: user.id, + reviewId: reviewId, + }, + }, + }); + + const review = await this.getReview(user.id, reviewId); + + return this.transformReview(review, user.id); + } } From 33fdccc2198edb34b881d2aebb6a553cc3dabd59 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Tue, 21 May 2024 16:27:29 +0300 Subject: [PATCH 17/32] add ability to modify and update own review --- src/reviews/DTO/update-review.dto.ts | 7 ++++ src/reviews/reviews.controller.ts | 56 +++++++++++++++++++++++--- src/reviews/reviews.service.ts | 60 ++++++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 src/reviews/DTO/update-review.dto.ts diff --git a/src/reviews/DTO/update-review.dto.ts b/src/reviews/DTO/update-review.dto.ts new file mode 100644 index 0000000..d58039b --- /dev/null +++ b/src/reviews/DTO/update-review.dto.ts @@ -0,0 +1,7 @@ +export class UpdateReviewDto { + comment?: string; + professionalism?: number; + reliability?: number; + communication?: number; + isAnonymous?: boolean; +} diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index e7681eb..92a8df1 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -3,27 +3,35 @@ import { Controller, Delete, Get, - Logger, Param, Post, + Put, } from '@nestjs/common'; import { ReviewsService } from './reviews.service'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { ReviewDto } from './DTO/reviews.dto'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { User } from '@prisma/client'; import { CreateReviewBodyDTO } from './DTO/create-review.dto'; import { RatingDto } from './DTO/rating.dto'; +import { UpdateReviewDto } from './DTO/update-review.dto'; @Controller('reviews') @ApiBearerAuth() @ApiTags('Reviews controller') export class ReviewsController { - private logger: Logger; constructor(private readonly reviewsService: ReviewsService) {} + /** + * Get reviews for user with ID userID + */ @Get('/:userId') - @ApiOperation({ summary: `Get reviews for user with ID userID` }) async getReviews( @CurrentUser() user: User, @Param('userId') postedToId: string, @@ -31,8 +39,11 @@ export class ReviewsController { return this.reviewsService.getUserReviews(user, postedToId); } + /** + * + * Create a review + */ @Post() - @ApiOperation({ summary: 'Create a review.' }) async createReview( @CurrentUser() user: User, @Body() { postedToId, review }: CreateReviewBodyDTO, @@ -57,6 +68,7 @@ export class ReviewsController { @CurrentUser() user: User, @Param('reviewId') reviewId: string, ): Promise { + console.log('here:)'); return this.reviewsService.likeReview(user, reviewId); } @@ -70,4 +82,38 @@ export class ReviewsController { ): Promise { return this.reviewsService.unlikeReview(user, reviewId); } + + /** + * + * Delete a review. Only the user that posted the review can do this. + * @param reviewId + */ + @Delete('/:reviewId') + @ApiNotFoundResponse() + @ApiForbiddenResponse() + async deleteReview( + @CurrentUser() user: User, + @Param('reviewId') reviewId: string, + ) { + await this.reviewsService.canUserModifyReview(user.id, reviewId); + await this.reviewsService.deleteReview(reviewId); + return { ok: true }; + } + + /** + * Update a review. Only the user that posted the review can do this. + */ + + @Put('/:reviewId') + @ApiNotFoundResponse() + @ApiForbiddenResponse() + async updateReview( + @CurrentUser() user: User, + @Param('reviewId') reviewId: string, + @Body() data: UpdateReviewDto, + ) { + await this.reviewsService.canUserModifyReview(user.id, reviewId); + + return this.updateReview(user, reviewId, data); + } } diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index 651f4fc..59c403c 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Inject, Injectable, NotFoundException, @@ -11,6 +12,7 @@ import { REDIS_CLIENT } from 'src/provider/redis.provider'; import Redis from 'ioredis'; import { CreateReviewDto } from './DTO/create-review.dto'; import { RatingDto } from './DTO/rating.dto'; +import { UpdateReviewDto } from './DTO/update-review.dto'; @Injectable() export class ReviewsService { @@ -40,7 +42,8 @@ export class ReviewsService { comment: review.comment, createdAt: review.createdAt, isOwnReview: review.postedById == currentUserId, - postedToId: review.postedToId, + // hide "postedBy" for anonymous reviews + postedToId: review.anonymous ? undefined : review.postedToId, id: review.id, isFavorite: !!review.favorites.find((f) => f.userId === currentUserId), state: review.state, @@ -85,7 +88,9 @@ export class ReviewsService { }, }); - return ratings.map((review) => this.transformReview(review, user.id)); + return ratings + .map((review) => this.transformReview(review, user.id)) + .filter((r) => r.state === 'APPROVED' || r.isOwnReview); } async createReview( @@ -113,7 +118,7 @@ export class ReviewsService { const review = await this.prisma.review.create({ data: { postedToId: postedToId, - postedById: ratingDto.anonymous ? null : user.id, + postedById: user.id, professionalism: ratingDto.professionalism, reliability: ratingDto.reliability, communication: ratingDto.communication, @@ -163,7 +168,7 @@ export class ReviewsService { } async likeReview(user: User, reviewId: Review['id']): Promise { - this.prisma.favoriteReview.upsert({ + await this.prisma.favoriteReview.upsert({ where: { userId_reviewId: { userId: user.id, @@ -195,4 +200,51 @@ export class ReviewsService { return this.transformReview(review, user.id); } + + // check if the review with reviewId is posted by the user with userID + async canUserModifyReview(userId: User['id'], reviewId: Review['id']) { + const review = await this.getReview(userId, reviewId); + + if (!review) { + throw new NotFoundException('Review not found'); + } + + if (review.postedById !== userId) { + throw new ForbiddenException( + `User ${userId} has no access to review ${reviewId}`, + ); + } + + return true; + } + + async deleteReview(reviewId: Review['id']) { + return this.prisma.review.delete({ + where: { + id: reviewId, + }, + }); + } + + async updateReviewDto( + user: User, + reviewId: string, + data: UpdateReviewDto, + ): Promise { + const review = await this.prisma.review.update({ + where: { + id: reviewId, + }, + data: { + professionalism: data.professionalism, + communication: data.communication, + reliability: data.reliability, + comment: data.comment, + anonymous: data.isAnonymous, + }, + include: this.includeWithReview(user.id), + }); + + return this.transformReview(review, user.id); + } } From accdd123491320dea08768cb2d21e18e171d47dc Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Wed, 22 May 2024 10:07:57 +0300 Subject: [PATCH 18/32] feat: add connections controller and move specific endpoints from user controller here --- src/app/app.module.ts | 2 + src/connections/DTO/Connection.dto.ts | 17 +++ .../connections.controller.spec.ts | 18 +++ src/connections/connections.controller.ts | 82 +++++++++++ src/connections/connections.module.ts | 9 ++ src/connections/connections.service.spec.ts | 18 +++ src/connections/connections.service.ts | 132 ++++++++++++++++++ src/user/controller/user.controller.ts | 49 +------ src/user/service/user.service.ts | 62 -------- 9 files changed, 279 insertions(+), 110 deletions(-) create mode 100644 src/connections/DTO/Connection.dto.ts create mode 100644 src/connections/connections.controller.spec.ts create mode 100644 src/connections/connections.controller.ts create mode 100644 src/connections/connections.module.ts create mode 100644 src/connections/connections.service.spec.ts create mode 100644 src/connections/connections.service.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a4fe07f..4b6b0d8 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,6 +10,7 @@ import { PrismaModule } from '../prisma/prisma.module'; import { MailModule } from '../mail/mail.module'; import { ProviderModule } from '../provider/provider.module'; import { ReviewsModule } from 'src/reviews/reviews.module'; +import { ConnectionsModule } from 'src/connections/connections.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { ReviewsModule } from 'src/reviews/reviews.module'; MailModule, ProviderModule, ReviewsModule, + ConnectionsModule, ], controllers: [AppController], providers: [ diff --git a/src/connections/DTO/Connection.dto.ts b/src/connections/DTO/Connection.dto.ts new file mode 100644 index 0000000..7623345 --- /dev/null +++ b/src/connections/DTO/Connection.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsDate } from 'class-validator'; + +export class ConnectionDto { + name?: string; + headline?: string; + profilePictureUrl?: string; + id: string; + email: string; + @Type(() => Date) + @IsDate() + joinedAt: Date; + + reviewsCount: number; + connectionsCount: number; + isConnection: boolean; +} diff --git a/src/connections/connections.controller.spec.ts b/src/connections/connections.controller.spec.ts new file mode 100644 index 0000000..5634f24 --- /dev/null +++ b/src/connections/connections.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConnectionsController } from './connections.controller'; + +describe('ConnectionsController', () => { + let controller: ConnectionsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ConnectionsController], + }).compile(); + + controller = module.get(ConnectionsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/connections/connections.controller.ts b/src/connections/connections.controller.ts new file mode 100644 index 0000000..f9308b8 --- /dev/null +++ b/src/connections/connections.controller.ts @@ -0,0 +1,82 @@ +import { Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { CurrentUser } from 'src/decorators/current-user.decorator'; +import { ConnectionsService } from './connections.service'; +import { ConnectionDto } from './DTO/Connection.dto'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiNotFoundResponse, +} from '@nestjs/swagger'; +import { User } from '@prisma/client'; +import { Public } from 'src/decorators/public.decorator'; + +@ApiBearerAuth() +@Controller('connections') +export class ConnectionsController { + constructor(private connectionsService: ConnectionsService) {} + /** + * + * Get all of the current user's connections + */ + @Get() + async getAllConnections(@CurrentUser() user: User): Promise { + return this.connectionsService.getUserConnections(user.id); + } + + /** + * Get "suggested for review" connections. + */ + @Get('/suggested') + async getSuggestedConnections( + @CurrentUser() user: User, + ): Promise { + return this.connectionsService.getUserConnections(user.id); + } + + /** + * Search an user by query. + */ + @Get('/search/:query') + @Public() + @ApiBadRequestResponse() + async searchUser( + @Param('query') query: string, + @CurrentUser() user?: User, + ): Promise { + return this.connectionsService.searchUsers(user?.id, query); + } + + /** + * Create a connection between current User and another user. + */ + @Post('/connect/:userId') + async connectWithUser( + @CurrentUser() user: User, + @Param('userId') userId: string, + ): Promise { + return this.connectionsService.connectWithUser(user.id, userId); + } + /** + * + * Remove connection between current user and another user. + */ + @Delete('/connect/:userId') + async unconnectWithUser( + @CurrentUser() user: User, + @Param('userId') userId: string, + ): Promise { + return this.connectionsService.unconnectWithUser(user.id, userId); + } + + /** + * Get a connection. + */ + @Get('/:userId') + @ApiNotFoundResponse() + async getUser( + @CurrentUser() user: User, + @Param('userId') userId: string, + ): Promise { + return this.connectionsService.getConnection(userId, user.id); + } +} diff --git a/src/connections/connections.module.ts b/src/connections/connections.module.ts new file mode 100644 index 0000000..c3becdd --- /dev/null +++ b/src/connections/connections.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ConnectionsService } from './connections.service'; +import { ConnectionsController } from './connections.controller'; + +@Module({ + providers: [ConnectionsService], + controllers: [ConnectionsController], +}) +export class ConnectionsModule {} diff --git a/src/connections/connections.service.spec.ts b/src/connections/connections.service.spec.ts new file mode 100644 index 0000000..bf46498 --- /dev/null +++ b/src/connections/connections.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConnectionsService } from './connections.service'; + +describe('ConnectionsService', () => { + let service: ConnectionsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ConnectionsService], + }).compile(); + + service = module.get(ConnectionsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts new file mode 100644 index 0000000..4b57977 --- /dev/null +++ b/src/connections/connections.service.ts @@ -0,0 +1,132 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { User } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { ConnectionDto } from './DTO/Connection.dto'; +import { ApiTags } from '@nestjs/swagger'; + +@Injectable() +@ApiTags('Connections controller') +export class ConnectionsService { + constructor(private readonly prisma: PrismaService) {} + + // retuns an object to be used with prisma's include + private includeWithUserConnection(currentUserId?: User['id']) { + return { + _count: { + select: { + followings: true, + reviewsReceived: true, + }, + }, + followers: currentUserId + ? { + where: { + followerId: currentUserId, + }, + } + : false, + }; + } + + // transforms an user from db to a Connection DTO + private transformUserConnection( + user: User & { _count: { followings: number; reviewsReceived: number } } & { + followers?: { id: number; followerId: string; followingId: string }[]; + }, + ): ConnectionDto { + return { + id: user.id, + email: user.email, + name: user.name, + headline: user.headline, + profilePictureUrl: user.profilePictureUrl, + + joinedAt: user.joinedAt, + + reviewsCount: user._count.reviewsReceived, + connectionsCount: user._count.followings, + isConnection: user.followers && user.followers.length !== 0, + }; + } + + async getConnection(connectionId: User['id'], currentUserId: User['id']) { + const user = await this.prisma.user.findUnique({ + where: { + id: connectionId, + }, + include: this.includeWithUserConnection(currentUserId), + }); + + if (!user) { + throw new NotFoundException(`User with ${connectionId} not found`); + } + + return this.transformUserConnection(user); + } + + async getUserConnections(userId: User['id']) { + const connections = await this.prisma.connection.findMany({ + where: { + followerId: userId, + }, + include: { + following: { + include: this.includeWithUserConnection(userId), + }, + }, + }); + + return connections.map((c) => this.transformUserConnection(c.following)); + } + + async connectWithUser(currentUserId: User['id'], userId: User['id']) { + await this.prisma.connection.upsert({ + where: { + followerId_followingId: { + followerId: currentUserId, + followingId: userId, + }, + }, + update: {}, + create: { + followerId: currentUserId, + followingId: userId, + }, + }); + + return this.getConnection(userId, currentUserId); + } + + async unconnectWithUser(currentUserId: User['id'], userId: User['id']) { + await this.prisma.connection.delete({ + where: { + followerId_followingId: { + followerId: currentUserId, + followingId: userId, + }, + }, + }); + return this.getConnection(userId, currentUserId); + } + + async searchUsers(userId?: User['id'], searchTerm?: string) { + if (!searchTerm || searchTerm === '') { + throw new BadRequestException('Search term is required'); + } + const users = await this.prisma.user.findMany({ + where: { + OR: [ + { email: { contains: searchTerm, mode: 'insensitive' } }, + { name: { contains: searchTerm, mode: 'insensitive' } }, + ], + }, + include: this.includeWithUserConnection(userId), + }); + + return users.map((u) => this.transformUserConnection(u)); + } +} diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index 0006cda..e7f10d3 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -4,7 +4,6 @@ import { Controller, Get, Param, - Post, Put, Req, Res, @@ -13,23 +12,16 @@ import { import { CurrentUser } from '../../decorators/current-user.decorator'; import { UserService } from '../service/user.service'; import { SocialAccountType, User } from '@prisma/client'; -import { RatingDto } from '../dto/rating.dto'; -import { User } from '@prisma/client'; import { UpdateUserDto } from '../dto/update-user.dto'; import { - ApiBadRequestResponse, ApiBearerAuth, ApiBody, - ApiConsumes, - ApiConflictResponse, - ApiCreatedResponse, ApiInternalServerErrorResponse, ApiOkResponse, ApiOperation, - ApiQuery, ApiTags, } from '@nestjs/swagger'; -import { userExtraProps, userProperties } from '../../schemas/user.properties'; +import { userProperties } from '../../schemas/user.properties'; import { Public } from '../../decorators/public.decorator'; import { Request, Response } from 'express'; @@ -61,25 +53,6 @@ export class UserController { return this.userService.getSelf(user); } - @Get('/:userId') - @ApiOperation({ - summary: 'Get another user', - description: 'Get the currently logged in user', - }) - @ApiOkResponse({ - description: 'User found', - schema: { - type: 'object', - properties: { ...userExtraProps, ...userProperties }, - }, - }) - async getUser( - @CurrentUser() user: User, - @Param('userId') userId: User['id'], - ) { - return this.userService.getUser(user.id, userId); - } - @Put() @ApiOperation({ summary: 'Update current user', @@ -149,26 +122,6 @@ export class UserController { res.status(302).redirect('/api/user/link-social/linkedin/callback'); } - @Get('search') - @ApiOperation({ - summary: 'Search users', - description: 'Search for users', - }) - @ApiOkResponse({ - description: 'Users found', - schema: { - type: 'array', - items: { - type: 'object', - properties: { ...userExtraProps, ...userProperties }, - }, - }, - }) - @Public() - async searchUsers(@Param('query') query: string) { - return this.userService.searchUsers(query); - } - @Public() @Get('search-by-external-profile/:profileUrl') @ApiOperation({ diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index d707014..71a2cba 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -76,52 +76,6 @@ export class UserService { } } - //user props returned to backend - private selectUserWithExtraProps(currentUserId: User['id']) { - return { - id: true, - email: true, - name: true, - joinedAt: true, - isEmailVerified: true, - headline: true, - profilePictureUrl: true, - followings: { - where: { - followerId: currentUserId, - }, - }, - _count: { - select: { - followings: true, - ratingsReceived: true, - }, - }, - }; - } - - async searchUsers(userId: User['id'], searchTerm?: string) { - if (!searchTerm) { - throw new BadRequestException('Search term is required'); - } - const users = await this.prisma.user.findMany({ - where: { - OR: [ - { email: { contains: searchTerm } }, - { name: { contains: searchTerm } }, - ], - }, - select: this.selectUserWithExtraProps(userId), - }); - - return users.map(({ _count, followings, ...user }) => ({ - connectionsCount: _count.followings, - ratingsCount: _count.ratingsReceived, - isConnection: followings.length != 0, - ...user, - })); - } - /** * This function aims to either create a social account link for the user. If a social * account with the same profile URL already exists, it will be linked to the user, and @@ -228,22 +182,6 @@ export class UserService { return await this.prisma.user.findUnique({ where: { id } }); } - public async getUser(currentUserId: User['id'], id: User['id']) { - const userWithCounts = await this.prisma.user.findUnique({ - where: { id }, - select: this.selectUserWithExtraProps(currentUserId), - }); - - const { _count, followings, ...user } = userWithCounts; - - return { - connectionsCount: _count.followings, - ratingsCount: _count.ratingsReceived, - isConnection: followings.length != 0, - ...user, - }; - } - private async findUserByEmail(email: string) { return await this.prisma.user.findUnique({ where: { From 8f6d6d8edcb0e0e287555fc3ed7576874d61049d Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Wed, 22 May 2024 12:52:10 +0300 Subject: [PATCH 19/32] fix: review annonymous property --- src/reviews/reviews.controller.ts | 1 - src/reviews/reviews.service.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index 92a8df1..e63aae7 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -68,7 +68,6 @@ export class ReviewsController { @CurrentUser() user: User, @Param('reviewId') reviewId: string, ): Promise { - console.log('here:)'); return this.reviewsService.likeReview(user, reviewId); } diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index 59c403c..298943f 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -35,7 +35,7 @@ export class ReviewsService { name: review.postedBy.name, } : undefined, - isAnonymous: !review.postedBy, + isAnonymous: review.anonymous, professionalism: review.professionalism, reliability: review.reliability, communication: review.communication, From 54011bad36620eeaba63257ea0383fc808bb95e5 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Wed, 22 May 2024 16:31:01 +0300 Subject: [PATCH 20/32] fix: add isEmailVerified to connection --- src/connections/DTO/Connection.dto.ts | 1 + src/connections/connections.service.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/connections/DTO/Connection.dto.ts b/src/connections/DTO/Connection.dto.ts index 7623345..01776b7 100644 --- a/src/connections/DTO/Connection.dto.ts +++ b/src/connections/DTO/Connection.dto.ts @@ -5,6 +5,7 @@ export class ConnectionDto { name?: string; headline?: string; profilePictureUrl?: string; + isEmailVerified: boolean; id: string; email: string; @Type(() => Date) diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index 4b57977..a8397ad 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -44,7 +44,7 @@ export class ConnectionsService { name: user.name, headline: user.headline, profilePictureUrl: user.profilePictureUrl, - + isEmailVerified: user.isEmailVerified, joinedAt: user.joinedAt, reviewsCount: user._count.reviewsReceived, From 859906c974d8e4ae4d391cb197e07ac25185cbcb Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Wed, 22 May 2024 17:20:53 +0300 Subject: [PATCH 21/32] fix rebase errors --- src/prisma/schema.prisma | 2 +- src/user/service/user.service.ts | 3 +-- src/user/user.e2e.spec.ts | 12 ++++++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 9192e04..668609b 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -26,7 +26,7 @@ enum SocialAccountType { model User { id String @id @default(cuid()) - email String @unique + email String? @unique name String? profilePictureUrl String? socialAccounts SocialAccount[] diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 71a2cba..9de19f6 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -4,7 +4,6 @@ import { Injectable, InternalServerErrorException, Logger, - NotFoundException, } from '@nestjs/common'; import { AuthType, SocialAccountType, User } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; @@ -108,7 +107,7 @@ export class UserService { // TODO: Merge account here await this.prisma.$transaction([ // Update all the ratings to the existing account with the current user - this.prisma.rating.updateMany({ + this.prisma.review.updateMany({ where: { postedToId: socialAccountUser.id }, data: { postedToId: currentUser.id }, }), diff --git a/src/user/user.e2e.spec.ts b/src/user/user.e2e.spec.ts index 994bdad..b10e220 100644 --- a/src/user/user.e2e.spec.ts +++ b/src/user/user.e2e.spec.ts @@ -117,7 +117,7 @@ describe('User Controller Tests', () => { expect(updatedUser.name).toBe('John Doe'); }); - describe('Rating tests', () => { + describe('review tests', () => { beforeEach(async () => { await prisma.user.create({ data: { @@ -129,7 +129,7 @@ describe('User Controller Tests', () => { }, }); - await prisma.rating.createMany({ + await prisma.review.createMany({ data: [ { postedToId: '2', @@ -149,7 +149,7 @@ describe('User Controller Tests', () => { ], }); - await prisma.rating.create({ + await prisma.review.create({ data: { postedToId: '1', postedById: '2', @@ -299,7 +299,7 @@ describe('User Controller Tests', () => { expect(response.json().reliability).toBe(5); expect(response.json().communication).toBe(5); - const rating = await prisma.rating.findUnique({ + const rating = await prisma.review.findUnique({ where: { id: response.json().id, }, @@ -335,7 +335,7 @@ describe('User Controller Tests', () => { expect(response.json().communication).toBe(5); expect(response.json().anonymous).toBe(true); - const rating = await prisma.rating.findUnique({ + const rating = await prisma.review.findUnique({ where: { id: response.json().id, }, @@ -434,7 +434,7 @@ describe('User Controller Tests', () => { afterAll(async () => { try { await prisma.user.deleteMany(); - await prisma.rating.deleteMany(); + await prisma.review.deleteMany(); await app.close(); } catch (error) { console.log('error', error); From 0854c7272bb088239b0488cfb66200b7c2213ffd Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Wed, 22 May 2024 18:26:22 +0300 Subject: [PATCH 22/32] Move search by profile url in connections. * Add authType property on connectionDto * remove jobTitle property. Use headline everywhere --- src/connections/DTO/Connection.dto.ts | 8 ++- src/connections/connections.controller.ts | 15 +++++ src/connections/connections.service.ts | 49 ++++++++++++++- .../migrations/20240522152425_/migration.sql | 8 +++ src/prisma/schema.prisma | 1 - src/schemas/user.properties.ts | 1 - src/user/controller/user.controller.ts | 23 -------- .../profile-fetcher/base.profile-fetcher.ts | 2 +- .../linkedin.profile-fetcher.ts | 2 +- src/user/service/user.service.ts | 59 +------------------ 10 files changed, 80 insertions(+), 88 deletions(-) create mode 100644 src/prisma/migrations/20240522152425_/migration.sql diff --git a/src/connections/DTO/Connection.dto.ts b/src/connections/DTO/Connection.dto.ts index 01776b7..3e5389d 100644 --- a/src/connections/DTO/Connection.dto.ts +++ b/src/connections/DTO/Connection.dto.ts @@ -1,5 +1,6 @@ +import { AuthType } from '@prisma/client'; import { Type } from 'class-transformer'; -import { IsDate } from 'class-validator'; +import { IsDate, IsEnum } from 'class-validator'; export class ConnectionDto { name?: string; @@ -7,7 +8,7 @@ export class ConnectionDto { profilePictureUrl?: string; isEmailVerified: boolean; id: string; - email: string; + email?: string; @Type(() => Date) @IsDate() joinedAt: Date; @@ -15,4 +16,7 @@ export class ConnectionDto { reviewsCount: number; connectionsCount: number; isConnection: boolean; + + @IsEnum(AuthType) + authType: AuthType; } diff --git a/src/connections/connections.controller.ts b/src/connections/connections.controller.ts index f9308b8..0c1c070 100644 --- a/src/connections/connections.controller.ts +++ b/src/connections/connections.controller.ts @@ -68,6 +68,21 @@ export class ConnectionsController { return this.connectionsService.unconnectWithUser(user.id, userId); } + /** + * Search for users by external profile URL + * @param profileUrlBase64 + */ + + @Public() + @Get('search-by-external-profile/:profileUrl') + async searchUsersByExternalProfile( + @Param('profileUrl') profileUrlBase64: string, + ) { + return this.connectionsService.searchUserByExternalProfile( + profileUrlBase64, + ); + } + /** * Get a connection. */ diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index a8397ad..16d9d03 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -3,10 +3,12 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { User } from '@prisma/client'; +import { AuthType, User } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { ConnectionDto } from './DTO/Connection.dto'; import { ApiTags } from '@nestjs/swagger'; +import { ProfileFetcherDelegator } from 'src/user/profile-fetcher/delegator.profile-fetcher'; +import { v4 } from 'uuid'; @Injectable() @ApiTags('Connections controller') @@ -46,7 +48,7 @@ export class ConnectionsService { profilePictureUrl: user.profilePictureUrl, isEmailVerified: user.isEmailVerified, joinedAt: user.joinedAt, - + authType: user.authType, reviewsCount: user._count.reviewsReceived, connectionsCount: user._count.followings, isConnection: user.followers && user.followers.length !== 0, @@ -129,4 +131,47 @@ export class ConnectionsService { return users.map((u) => this.transformUserConnection(u)); } + + async searchUserByExternalProfile(profileUrlBase64: string) { + // Check if the profile by url exists + // If the account exists, we just return the user associated with it. + const profileUrl = Buffer.from(profileUrlBase64, 'base64').toString(); + + const socialAccount = await this.prisma.socialAccount.findFirst({ + where: { profileUrl }, + include: { + user: true, + }, + }); + if (socialAccount) return socialAccount.user; + + // Fetch the profile details + const profileData = await new ProfileFetcherDelegator( + profileUrl, + ).getProfileDetails(); + + // Else, we create a new user, associate the social account with it, and return the user. + + const newUserId = v4(); + const [newUser] = await this.prisma.$transaction([ + this.prisma.user.create({ + data: { + id: newUserId, + name: profileData.name, + authType: AuthType.EXTERNAL, + headline: profileData.headline, + profilePictureUrl: profileData.profilePictureUrl, + }, + include: this.includeWithUserConnection(), + }), + this.prisma.socialAccount.create({ + data: { + platform: profileData.socialAccountType, + profileUrl, + userId: newUserId, + }, + }), + ]); + return this.transformUserConnection(newUser); + } } diff --git a/src/prisma/migrations/20240522152425_/migration.sql b/src/prisma/migrations/20240522152425_/migration.sql new file mode 100644 index 0000000..3689f56 --- /dev/null +++ b/src/prisma/migrations/20240522152425_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `jobTitle` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "jobTitle"; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 668609b..5b56235 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -33,7 +33,6 @@ model User { authType AuthType isEmailVerified Boolean @default(false) headline String? - jobTitle String? followers Connection[] @relation("followsTheUser") followings Connection[] @relation("followedByUser") reviewsPosted Review[] @relation("reviewsGivenToOtherUsers") diff --git a/src/schemas/user.properties.ts b/src/schemas/user.properties.ts index 3cc40c6..761f813 100644 --- a/src/schemas/user.properties.ts +++ b/src/schemas/user.properties.ts @@ -9,7 +9,6 @@ export const userProperties = { }, isEmailVerified: { type: 'boolean' }, headline: { type: 'string' }, - jobTitle: { type: 'string' }, joinedAt: { type: 'string', format: 'date-time' }, }; diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index e7f10d3..9de45e4 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -3,7 +3,6 @@ import { Body, Controller, Get, - Param, Put, Req, Res, @@ -121,26 +120,4 @@ export class UserController { res.status(302).redirect('/api/user/link-social/linkedin/callback'); } - - @Public() - @Get('search-by-external-profile/:profileUrl') - @ApiOperation({ - summary: 'Search users by external profile', - description: 'Search for users by external profile URL', - }) - @ApiOkResponse({ - description: 'Users found', - schema: { - type: 'array', - items: { - type: 'object', - properties: userProperties, - }, - }, - }) - async searchUsersByExternalProfile( - @Param('profileUrl') profileUrlBase64: string, - ) { - return this.userService.searchUserByExternalProfile(profileUrlBase64); - } } diff --git a/src/user/profile-fetcher/base.profile-fetcher.ts b/src/user/profile-fetcher/base.profile-fetcher.ts index 6cdd7f0..ea1ddc8 100644 --- a/src/user/profile-fetcher/base.profile-fetcher.ts +++ b/src/user/profile-fetcher/base.profile-fetcher.ts @@ -10,6 +10,6 @@ export interface ProfileDetails { profileUrl: string; socialAccountType: SocialAccountType; name?: string; - jobTitle?: string; + headline?: string; profilePictureUrl?: string; } diff --git a/src/user/profile-fetcher/linkedin.profile-fetcher.ts b/src/user/profile-fetcher/linkedin.profile-fetcher.ts index eb70910..54f3123 100644 --- a/src/user/profile-fetcher/linkedin.profile-fetcher.ts +++ b/src/user/profile-fetcher/linkedin.profile-fetcher.ts @@ -23,7 +23,7 @@ export class LinkedInProfileFetcher extends ProfileFetcher { name: data.data.full_name, socialAccountType: SocialAccountType.LINKEDIN, profileUrl: this.profileUrl, - jobTitle: data.data.job_title, + headline: data.data.job_title, profilePictureUrl: data.data.profile_image_url, }; } else { diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 9de19f6..2ddf86c 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -5,15 +5,14 @@ import { InternalServerErrorException, Logger, } from '@nestjs/common'; -import { AuthType, SocialAccountType, User } from '@prisma/client'; +import { SocialAccountType, User } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { UpdateUserDto } from '../dto/update-user.dto'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { S3_CLIENT } from '../../provider/s3.provider'; import { REDIS_CLIENT } from '../../provider/redis.provider'; import { Redis } from 'ioredis'; -import { ProfileFetcherDelegator } from '../profile-fetcher/delegator.profile-fetcher'; -import { v4 } from 'uuid'; + import { getMimeType } from 'utils/image'; @Injectable() @@ -134,58 +133,4 @@ export class UserService { }); } } - - async searchUserByExternalProfile(profileUrlBase64: string) { - // Check if the profile by url exists - // If the account exists, we just return the user associated with it. - const profileUrl = Buffer.from(profileUrlBase64, 'base64').toString(); - - const socialAccount = await this.prisma.socialAccount.findFirst({ - where: { profileUrl }, - include: { - user: true, - }, - }); - if (socialAccount) return socialAccount.user; - - // Fetch the profile details - const profileData = await new ProfileFetcherDelegator( - profileUrl, - ).getProfileDetails(); - - // Else, we create a new user, associate the social account with it, and return the user. - - const newUserId = v4(); - const [newUser] = await this.prisma.$transaction([ - this.prisma.user.create({ - data: { - id: newUserId, - name: profileData.name, - authType: AuthType.EXTERNAL, - jobTitle: profileData.jobTitle, - profilePictureUrl: profileData.profilePictureUrl, - }, - }), - this.prisma.socialAccount.create({ - data: { - platform: profileData.socialAccountType, - profileUrl, - userId: newUserId, - }, - }), - ]); - return newUser; - } - - private async findUserById(id: string) { - return await this.prisma.user.findUnique({ where: { id } }); - } - - private async findUserByEmail(email: string) { - return await this.prisma.user.findUnique({ - where: { - email, - }, - }); - } } From dfea6a6f9919b39dae82ab3912c7f704bfb111ef Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Thu, 23 May 2024 10:02:38 +0300 Subject: [PATCH 23/32] Return transformed user when social account is already existen --- src/connections/connections.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index 16d9d03..d31325d 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -140,10 +140,10 @@ export class ConnectionsService { const socialAccount = await this.prisma.socialAccount.findFirst({ where: { profileUrl }, include: { - user: true, + user: { include: this.includeWithUserConnection() }, }, }); - if (socialAccount) return socialAccount.user; + if (socialAccount) return this.transformUserConnection(socialAccount.user); // Fetch the profile details const profileData = await new ProfileFetcherDelegator( From e7bf445f424bada22863787cb5eb2147dff0d51d Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Thu, 23 May 2024 13:26:26 +0300 Subject: [PATCH 24/32] add logging --- src/app/app.module.ts | 9 +++++-- src/middlewares/logger.middleware.ts | 40 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/middlewares/logger.middleware.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4b6b0d8..c765b88 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { AppController } from './app.controller'; import { AuthModule } from '../auth/auth.module'; import { UserModule } from '../user/user.module'; @@ -11,6 +11,7 @@ import { MailModule } from '../mail/mail.module'; import { ProviderModule } from '../provider/provider.module'; import { ReviewsModule } from 'src/reviews/reviews.module'; import { ConnectionsModule } from 'src/connections/connections.module'; +import { AppLoggerMiddleware } from 'src/middlewares/logger.middleware'; @Module({ imports: [ @@ -34,4 +35,8 @@ import { ConnectionsModule } from 'src/connections/connections.module'; }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(AppLoggerMiddleware).forRoutes('*'); + } +} diff --git a/src/middlewares/logger.middleware.ts b/src/middlewares/logger.middleware.ts new file mode 100644 index 0000000..02da166 --- /dev/null +++ b/src/middlewares/logger.middleware.ts @@ -0,0 +1,40 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; + +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class AppLoggerMiddleware implements NestMiddleware { + private logger = new Logger('HTTP'); + + use(request: Request, response: Response, next: NextFunction): void { + const startAt = process.hrtime(); + const { ip, method, originalUrl } = request; + const userAgent = request.get('user-agent') || ''; + + const send = response.send; + response.send = (exitData) => { + if ( + response + ?.getHeader('content-type') + ?.toString() + .includes('application/json') + ) { + const { statusCode } = response; + const diff = process.hrtime(startAt); + const responseTime = diff[0] * 1e3 + diff[1] * 1e-6; + this.logger.log( + `${method} ${originalUrl} ${statusCode} ${responseTime}ms - ${userAgent} ${ip}`, + ); + console.log({ + code: response.statusCode, + exit: exitData.toString().substring(0, 1000), + endDate: new Date(), + }); + } + response.send = send; + return response.send(exitData); + }; + + next(); + } +} From 1b1966da1ddcf2ba79976809299f8edc6caafd9a Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Thu, 23 May 2024 17:59:11 +0300 Subject: [PATCH 25/32] fix reviews issues --- src/reviews/DTO/update-review.dto.ts | 14 +++++++- src/reviews/reviews.controller.ts | 14 +++++++- src/reviews/reviews.service.ts | 52 +++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/reviews/DTO/update-review.dto.ts b/src/reviews/DTO/update-review.dto.ts index d58039b..fb0517a 100644 --- a/src/reviews/DTO/update-review.dto.ts +++ b/src/reviews/DTO/update-review.dto.ts @@ -1,7 +1,19 @@ +import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; + export class UpdateReviewDto { + @IsString() + @IsOptional() comment?: string; + @IsNumber() + @IsOptional() professionalism?: number; + @IsNumber() + @IsOptional() reliability?: number; + @IsNumber() + @IsOptional() communication?: number; - isAnonymous?: boolean; + @IsBoolean() + @IsOptional() + anonymous?: boolean; } diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index e63aae7..f020747 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -28,6 +28,18 @@ import { UpdateReviewDto } from './DTO/update-review.dto'; export class ReviewsController { constructor(private readonly reviewsService: ReviewsService) {} + /** + * Get the review of the current user for the specified user + */ + @Get('/my-review/:userId') + @ApiNotFoundResponse() + async getMyReviewForUser( + @CurrentUser() user: User, + @Param('userId') userId: string, + ): Promise { + return this.reviewsService.getReviewByUserForUser(user.id, userId); + } + /** * Get reviews for user with ID userID */ @@ -113,6 +125,6 @@ export class ReviewsController { ) { await this.reviewsService.canUserModifyReview(user.id, reviewId); - return this.updateReview(user, reviewId, data); + return this.reviewsService.updateReview(user, reviewId, data); } } diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index 298943f..62001d4 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -5,7 +5,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { User, Review, FavoriteReview } from '@prisma/client'; +import { User, Review, FavoriteReview, ReviewState } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { ReviewDto } from './DTO/reviews.dto'; import { REDIS_CLIENT } from 'src/provider/redis.provider'; @@ -43,7 +43,7 @@ export class ReviewsService { createdAt: review.createdAt, isOwnReview: review.postedById == currentUserId, // hide "postedBy" for anonymous reviews - postedToId: review.anonymous ? undefined : review.postedToId, + postedToId: review.postedToId, id: review.id, isFavorite: !!review.favorites.find((f) => f.userId === currentUserId), state: review.state, @@ -124,6 +124,7 @@ export class ReviewsService { communication: ratingDto.communication, comment: ratingDto.comment, anonymous: ratingDto.anonymous, + state: ReviewState.APPROVED, }, include: this.includeWithReview(user.id), }); @@ -219,14 +220,29 @@ export class ReviewsService { } async deleteReview(reviewId: Review['id']) { - return this.prisma.review.delete({ + const review = await this.prisma.review.findUnique({ + where: { id: reviewId }, + }); + + if (!review) { + throw new NotFoundException('Review not found'); + } + await this.prisma.review.delete({ where: { id: reviewId, }, }); + + const avgRatings = await this.calculateAvgRating(review.postedToId); + await this.cache.set( + `avg-ratings-${review.postedToId}`, + JSON.stringify(avgRatings), + ); + + return true; } - async updateReviewDto( + async updateReview( user: User, reviewId: string, data: UpdateReviewDto, @@ -240,11 +256,37 @@ export class ReviewsService { communication: data.communication, reliability: data.reliability, comment: data.comment, - anonymous: data.isAnonymous, + anonymous: data.anonymous, }, include: this.includeWithReview(user.id), }); + // Update the cache + const avgRatings = await this.calculateAvgRating(review.postedToId); + await this.cache.set( + `avg-ratings-${review.postedToId}`, + JSON.stringify(avgRatings), + ); + return this.transformReview(review, user.id); } + + async getReviewByUserForUser( + currentUserId: string, + userId: string, + ): Promise { + const review = await this.prisma.review.findFirst({ + where: { + postedById: currentUserId, + postedToId: userId, + }, + include: this.includeWithReview(currentUserId), + }); + + if (!review) { + return undefined; + } + + return this.transformReview(review, currentUserId); + } } From f4612b49211d4db1effc16d9c675666b3f0f8ede Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 24 May 2024 11:27:46 +0300 Subject: [PATCH 26/32] rename dto folder --- src/connections/connections.controller.ts | 2 +- src/connections/connections.service.ts | 2 +- src/reviews/reviews.controller.ts | 17 +++++++++++++---- src/reviews/reviews.service.ts | 19 +++++++++++++++---- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/connections/connections.controller.ts b/src/connections/connections.controller.ts index 0c1c070..4bb02cf 100644 --- a/src/connections/connections.controller.ts +++ b/src/connections/connections.controller.ts @@ -1,7 +1,7 @@ import { Controller, Delete, Get, Param, Post } from '@nestjs/common'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { ConnectionsService } from './connections.service'; -import { ConnectionDto } from './DTO/Connection.dto'; +import { ConnectionDto } from './dto/Connection.dto'; import { ApiBadRequestResponse, ApiBearerAuth, diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index d31325d..d3af27a 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { AuthType, User } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; -import { ConnectionDto } from './DTO/Connection.dto'; +import { ConnectionDto } from './dto/Connection.dto'; import { ApiTags } from '@nestjs/swagger'; import { ProfileFetcherDelegator } from 'src/user/profile-fetcher/delegator.profile-fetcher'; import { v4 } from 'uuid'; diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index f020747..807c027 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -15,12 +15,12 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; -import { ReviewDto } from './DTO/reviews.dto'; +import { ReviewDto } from './dto/reviews.dto'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { User } from '@prisma/client'; -import { CreateReviewBodyDTO } from './DTO/create-review.dto'; -import { RatingDto } from './DTO/rating.dto'; -import { UpdateReviewDto } from './DTO/update-review.dto'; +import { CreateReviewBodyDTO } from './dto/create-review.dto'; +import { RatingDto } from './dto/rating.dto'; +import { UpdateReviewDto } from './dto/update-review.dto'; @Controller('reviews') @ApiBearerAuth() @@ -40,6 +40,15 @@ export class ReviewsController { return this.reviewsService.getReviewByUserForUser(user.id, userId); } + /** + * Get the reviews posted by the current user + */ + @Get('/posted') + @ApiNotFoundResponse() + async getReviewsPostedBy(@CurrentUser() user: User): Promise { + return this.reviewsService.getReviewPostedBy(user.id); + } + /** * Get reviews for user with ID userID */ diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index 62001d4..049ea5d 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -7,12 +7,12 @@ import { } from '@nestjs/common'; import { User, Review, FavoriteReview, ReviewState } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; -import { ReviewDto } from './DTO/reviews.dto'; +import { ReviewDto } from './dto/reviews.dto'; import { REDIS_CLIENT } from 'src/provider/redis.provider'; import Redis from 'ioredis'; -import { CreateReviewDto } from './DTO/create-review.dto'; -import { RatingDto } from './DTO/rating.dto'; -import { UpdateReviewDto } from './DTO/update-review.dto'; +import { CreateReviewDto } from './dto/create-review.dto'; +import { RatingDto } from './dto/rating.dto'; +import { UpdateReviewDto } from './dto/update-review.dto'; @Injectable() export class ReviewsService { @@ -289,4 +289,15 @@ export class ReviewsService { return this.transformReview(review, currentUserId); } + + async getReviewPostedBy(currentUserId: User['id']): Promise { + const reviews = await this.prisma.review.findMany({ + where: { + postedById: currentUserId, + }, + include: this.includeWithReview(currentUserId), + }); + + return reviews.map((r) => this.transformReview(r, currentUserId)); + } } From 0fa2298a0f11aeab31d661900c3897ca1d0984aa Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 24 May 2024 11:30:46 +0300 Subject: [PATCH 27/32] rename methods --- src/connections/connections.controller.ts | 4 ++-- src/connections/connections.service.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/connections/connections.controller.ts b/src/connections/connections.controller.ts index 4bb02cf..e2b67bf 100644 --- a/src/connections/connections.controller.ts +++ b/src/connections/connections.controller.ts @@ -54,7 +54,7 @@ export class ConnectionsController { @CurrentUser() user: User, @Param('userId') userId: string, ): Promise { - return this.connectionsService.connectWithUser(user.id, userId); + return this.connectionsService.addConnection(user.id, userId); } /** * @@ -65,7 +65,7 @@ export class ConnectionsController { @CurrentUser() user: User, @Param('userId') userId: string, ): Promise { - return this.connectionsService.unconnectWithUser(user.id, userId); + return this.connectionsService.removeConnection(user.id, userId); } /** diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index d3af27a..c7d5838 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -35,7 +35,7 @@ export class ConnectionsService { } // transforms an user from db to a Connection DTO - private transformUserConnection( + private convertConnectionToDto( user: User & { _count: { followings: number; reviewsReceived: number } } & { followers?: { id: number; followerId: string; followingId: string }[]; }, @@ -67,7 +67,7 @@ export class ConnectionsService { throw new NotFoundException(`User with ${connectionId} not found`); } - return this.transformUserConnection(user); + return this.convertConnectionToDto(user); } async getUserConnections(userId: User['id']) { @@ -82,10 +82,10 @@ export class ConnectionsService { }, }); - return connections.map((c) => this.transformUserConnection(c.following)); + return connections.map((c) => this.convertConnectionToDto(c.following)); } - async connectWithUser(currentUserId: User['id'], userId: User['id']) { + async addConnection(currentUserId: User['id'], userId: User['id']) { await this.prisma.connection.upsert({ where: { followerId_followingId: { @@ -103,7 +103,7 @@ export class ConnectionsService { return this.getConnection(userId, currentUserId); } - async unconnectWithUser(currentUserId: User['id'], userId: User['id']) { + async removeConnection(currentUserId: User['id'], userId: User['id']) { await this.prisma.connection.delete({ where: { followerId_followingId: { @@ -129,7 +129,7 @@ export class ConnectionsService { include: this.includeWithUserConnection(userId), }); - return users.map((u) => this.transformUserConnection(u)); + return users.map((u) => this.convertConnectionToDto(u)); } async searchUserByExternalProfile(profileUrlBase64: string) { @@ -143,7 +143,7 @@ export class ConnectionsService { user: { include: this.includeWithUserConnection() }, }, }); - if (socialAccount) return this.transformUserConnection(socialAccount.user); + if (socialAccount) return this.convertConnectionToDto(socialAccount.user); // Fetch the profile details const profileData = await new ProfileFetcherDelegator( @@ -172,6 +172,6 @@ export class ConnectionsService { }, }), ]); - return this.transformUserConnection(newUser); + return this.convertConnectionToDto(newUser); } } From 47a2e7f4fee00dd8c66102958c362792cf37027a Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 24 May 2024 11:34:40 +0300 Subject: [PATCH 28/32] make only one DB query for craeting a connection --- src/connections/connections.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index c7d5838..2e11ca4 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -86,7 +86,7 @@ export class ConnectionsService { } async addConnection(currentUserId: User['id'], userId: User['id']) { - await this.prisma.connection.upsert({ + const connection = await this.prisma.connection.upsert({ where: { followerId_followingId: { followerId: currentUserId, @@ -98,9 +98,14 @@ export class ConnectionsService { followerId: currentUserId, followingId: userId, }, + include: { + following: { + include: this.includeWithUserConnection(currentUserId), + }, + }, }); - return this.getConnection(userId, currentUserId); + return this.convertConnectionToDto(connection.following); } async removeConnection(currentUserId: User['id'], userId: User['id']) { From c6d93b382729de651a3fa250138d024f604df151 Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 24 May 2024 11:43:21 +0300 Subject: [PATCH 29/32] change DTO folder to a random name (because gh is not case sensitive and doesn't see lowering the case of the file as an actual change) --- src/connections/connections.controller.ts | 2 +- src/connections/connections.service.ts | 2 +- src/connections/{DTO => dto_2}/Connection.dto.ts | 0 src/reviews/{DTO => dto_2}/create-review.dto.ts | 0 src/reviews/{DTO => dto_2}/rating.dto.ts | 0 src/reviews/{DTO => dto_2}/reviews.dto.ts | 0 src/reviews/{DTO => dto_2}/update-review.dto.ts | 0 src/reviews/reviews.controller.ts | 8 ++++---- src/reviews/reviews.service.ts | 8 ++++---- 9 files changed, 10 insertions(+), 10 deletions(-) rename src/connections/{DTO => dto_2}/Connection.dto.ts (100%) rename src/reviews/{DTO => dto_2}/create-review.dto.ts (100%) rename src/reviews/{DTO => dto_2}/rating.dto.ts (100%) rename src/reviews/{DTO => dto_2}/reviews.dto.ts (100%) rename src/reviews/{DTO => dto_2}/update-review.dto.ts (100%) diff --git a/src/connections/connections.controller.ts b/src/connections/connections.controller.ts index e2b67bf..5502cda 100644 --- a/src/connections/connections.controller.ts +++ b/src/connections/connections.controller.ts @@ -1,7 +1,7 @@ import { Controller, Delete, Get, Param, Post } from '@nestjs/common'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { ConnectionsService } from './connections.service'; -import { ConnectionDto } from './dto/Connection.dto'; +import { ConnectionDto } from './dto_2/Connection.dto'; import { ApiBadRequestResponse, ApiBearerAuth, diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index 2e11ca4..ab337d9 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { AuthType, User } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; -import { ConnectionDto } from './dto/Connection.dto'; +import { ConnectionDto } from './dto_2/Connection.dto'; import { ApiTags } from '@nestjs/swagger'; import { ProfileFetcherDelegator } from 'src/user/profile-fetcher/delegator.profile-fetcher'; import { v4 } from 'uuid'; diff --git a/src/connections/DTO/Connection.dto.ts b/src/connections/dto_2/Connection.dto.ts similarity index 100% rename from src/connections/DTO/Connection.dto.ts rename to src/connections/dto_2/Connection.dto.ts diff --git a/src/reviews/DTO/create-review.dto.ts b/src/reviews/dto_2/create-review.dto.ts similarity index 100% rename from src/reviews/DTO/create-review.dto.ts rename to src/reviews/dto_2/create-review.dto.ts diff --git a/src/reviews/DTO/rating.dto.ts b/src/reviews/dto_2/rating.dto.ts similarity index 100% rename from src/reviews/DTO/rating.dto.ts rename to src/reviews/dto_2/rating.dto.ts diff --git a/src/reviews/DTO/reviews.dto.ts b/src/reviews/dto_2/reviews.dto.ts similarity index 100% rename from src/reviews/DTO/reviews.dto.ts rename to src/reviews/dto_2/reviews.dto.ts diff --git a/src/reviews/DTO/update-review.dto.ts b/src/reviews/dto_2/update-review.dto.ts similarity index 100% rename from src/reviews/DTO/update-review.dto.ts rename to src/reviews/dto_2/update-review.dto.ts diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index 807c027..edadae5 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -15,12 +15,12 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; -import { ReviewDto } from './dto/reviews.dto'; +import { ReviewDto } from './dto_2/reviews.dto'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { User } from '@prisma/client'; -import { CreateReviewBodyDTO } from './dto/create-review.dto'; -import { RatingDto } from './dto/rating.dto'; -import { UpdateReviewDto } from './dto/update-review.dto'; +import { CreateReviewBodyDTO } from './dto_2/create-review.dto'; +import { RatingDto } from './dto_2/rating.dto'; +import { UpdateReviewDto } from './dto_2/update-review.dto'; @Controller('reviews') @ApiBearerAuth() diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index 049ea5d..b276bf2 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -7,12 +7,12 @@ import { } from '@nestjs/common'; import { User, Review, FavoriteReview, ReviewState } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; -import { ReviewDto } from './dto/reviews.dto'; +import { ReviewDto } from './dto_2/reviews.dto'; import { REDIS_CLIENT } from 'src/provider/redis.provider'; import Redis from 'ioredis'; -import { CreateReviewDto } from './dto/create-review.dto'; -import { RatingDto } from './dto/rating.dto'; -import { UpdateReviewDto } from './dto/update-review.dto'; +import { CreateReviewDto } from './dto_2/create-review.dto'; +import { RatingDto } from './dto_2/rating.dto'; +import { UpdateReviewDto } from './dto_2/update-review.dto'; @Injectable() export class ReviewsService { From b7c5ab1119f4f5a468f2e0e5e4f440531372544b Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 24 May 2024 11:44:09 +0300 Subject: [PATCH 30/32] rename dto folder --- src/connections/connections.controller.ts | 2 +- src/connections/connections.service.ts | 2 +- src/connections/{dto_2 => dto}/Connection.dto.ts | 0 src/reviews/{dto_2 => dto}/create-review.dto.ts | 0 src/reviews/{dto_2 => dto}/rating.dto.ts | 0 src/reviews/{dto_2 => dto}/reviews.dto.ts | 0 src/reviews/{dto_2 => dto}/update-review.dto.ts | 0 src/reviews/reviews.controller.ts | 8 ++++---- src/reviews/reviews.service.ts | 8 ++++---- 9 files changed, 10 insertions(+), 10 deletions(-) rename src/connections/{dto_2 => dto}/Connection.dto.ts (100%) rename src/reviews/{dto_2 => dto}/create-review.dto.ts (100%) rename src/reviews/{dto_2 => dto}/rating.dto.ts (100%) rename src/reviews/{dto_2 => dto}/reviews.dto.ts (100%) rename src/reviews/{dto_2 => dto}/update-review.dto.ts (100%) diff --git a/src/connections/connections.controller.ts b/src/connections/connections.controller.ts index 5502cda..e2b67bf 100644 --- a/src/connections/connections.controller.ts +++ b/src/connections/connections.controller.ts @@ -1,7 +1,7 @@ import { Controller, Delete, Get, Param, Post } from '@nestjs/common'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { ConnectionsService } from './connections.service'; -import { ConnectionDto } from './dto_2/Connection.dto'; +import { ConnectionDto } from './dto/Connection.dto'; import { ApiBadRequestResponse, ApiBearerAuth, diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index ab337d9..2e11ca4 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { AuthType, User } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; -import { ConnectionDto } from './dto_2/Connection.dto'; +import { ConnectionDto } from './dto/Connection.dto'; import { ApiTags } from '@nestjs/swagger'; import { ProfileFetcherDelegator } from 'src/user/profile-fetcher/delegator.profile-fetcher'; import { v4 } from 'uuid'; diff --git a/src/connections/dto_2/Connection.dto.ts b/src/connections/dto/Connection.dto.ts similarity index 100% rename from src/connections/dto_2/Connection.dto.ts rename to src/connections/dto/Connection.dto.ts diff --git a/src/reviews/dto_2/create-review.dto.ts b/src/reviews/dto/create-review.dto.ts similarity index 100% rename from src/reviews/dto_2/create-review.dto.ts rename to src/reviews/dto/create-review.dto.ts diff --git a/src/reviews/dto_2/rating.dto.ts b/src/reviews/dto/rating.dto.ts similarity index 100% rename from src/reviews/dto_2/rating.dto.ts rename to src/reviews/dto/rating.dto.ts diff --git a/src/reviews/dto_2/reviews.dto.ts b/src/reviews/dto/reviews.dto.ts similarity index 100% rename from src/reviews/dto_2/reviews.dto.ts rename to src/reviews/dto/reviews.dto.ts diff --git a/src/reviews/dto_2/update-review.dto.ts b/src/reviews/dto/update-review.dto.ts similarity index 100% rename from src/reviews/dto_2/update-review.dto.ts rename to src/reviews/dto/update-review.dto.ts diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index edadae5..807c027 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -15,12 +15,12 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; -import { ReviewDto } from './dto_2/reviews.dto'; +import { ReviewDto } from './dto/reviews.dto'; import { CurrentUser } from 'src/decorators/current-user.decorator'; import { User } from '@prisma/client'; -import { CreateReviewBodyDTO } from './dto_2/create-review.dto'; -import { RatingDto } from './dto_2/rating.dto'; -import { UpdateReviewDto } from './dto_2/update-review.dto'; +import { CreateReviewBodyDTO } from './dto/create-review.dto'; +import { RatingDto } from './dto/rating.dto'; +import { UpdateReviewDto } from './dto/update-review.dto'; @Controller('reviews') @ApiBearerAuth() diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index b276bf2..049ea5d 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -7,12 +7,12 @@ import { } from '@nestjs/common'; import { User, Review, FavoriteReview, ReviewState } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; -import { ReviewDto } from './dto_2/reviews.dto'; +import { ReviewDto } from './dto/reviews.dto'; import { REDIS_CLIENT } from 'src/provider/redis.provider'; import Redis from 'ioredis'; -import { CreateReviewDto } from './dto_2/create-review.dto'; -import { RatingDto } from './dto_2/rating.dto'; -import { UpdateReviewDto } from './dto_2/update-review.dto'; +import { CreateReviewDto } from './dto/create-review.dto'; +import { RatingDto } from './dto/rating.dto'; +import { UpdateReviewDto } from './dto/update-review.dto'; @Injectable() export class ReviewsService { From 05753e7d7dc25b75e4da696123c7616c6ce8cc6f Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 24 May 2024 12:02:06 +0300 Subject: [PATCH 31/32] squash migrations --- .../migration.sql | 24 ------- .../migration.sql | 65 +++++++++++++++++++ .../migration.sql | 21 ------ .../migration.sql | 1 - .../migrations/20240522152425_/migration.sql | 8 --- 5 files changed, 65 insertions(+), 54 deletions(-) delete mode 100644 src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql create mode 100644 src/prisma/migrations/20240521095208_review_refactor/migration.sql delete mode 100644 src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql delete mode 100644 src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql delete mode 100644 src/prisma/migrations/20240522152425_/migration.sql diff --git a/src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql b/src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql deleted file mode 100644 index d903424..0000000 --- a/src/prisma/migrations/20240521095208_add_fields_to_review/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ --- CreateEnum -CREATE TYPE "ReviewState" AS ENUM ('PENDING', 'BLOCKED', 'APPROVED'); - --- AlterTable -ALTER TABLE "Review" ADD COLUMN "state" "ReviewState" NOT NULL DEFAULT 'PENDING', -ALTER COLUMN "anonymous" SET DEFAULT true; - --- CreateTable -CREATE TABLE "FavoriteReview" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "reviewId" INTEGER NOT NULL, - - CONSTRAINT "FavoriteReview_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "FavoriteReview_userId_reviewId_key" ON "FavoriteReview"("userId", "reviewId"); - --- AddForeignKey -ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/prisma/migrations/20240521095208_review_refactor/migration.sql b/src/prisma/migrations/20240521095208_review_refactor/migration.sql new file mode 100644 index 0000000..2df5bad --- /dev/null +++ b/src/prisma/migrations/20240521095208_review_refactor/migration.sql @@ -0,0 +1,65 @@ +-- CreateEnum +CREATE TYPE "ReviewState" AS ENUM ('PENDING', 'BLOCKED', 'APPROVED'); + +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "state" "ReviewState" NOT NULL DEFAULT 'PENDING', +ALTER COLUMN "anonymous" SET DEFAULT true; + +-- CreateTable +CREATE TABLE "FavoriteReview" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "reviewId" INTEGER NOT NULL, + + CONSTRAINT "FavoriteReview_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "FavoriteReview_userId_reviewId_key" ON "FavoriteReview"("userId", "reviewId"); + +-- AddForeignKey +ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +/** + - Adds favorite review field + - Make the id cuid +*/ +/* + Warnings: + + - The primary key for the `Review` table will be changed. If it partially fails, the table could be left without primary key constraint. + +*/ +-- DropForeignKey +ALTER TABLE "FavoriteReview" DROP CONSTRAINT "FavoriteReview_reviewId_fkey"; + +-- AlterTable +ALTER TABLE "FavoriteReview" ALTER COLUMN "reviewId" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "Review" DROP CONSTRAINT "Review_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "Review_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "Review_id_seq"; + +-- AddForeignKey +ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +/* + - drops 'jobTitle`. We are using `headline` instead. +*/ + +/* + Warnings: + + - You are about to drop the column `jobTitle` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "jobTitle"; diff --git a/src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql b/src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql deleted file mode 100644 index bfc0c95..0000000 --- a/src/prisma/migrations/20240521100144_cuid_id_reviews/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ -/* - Warnings: - - - The primary key for the `Review` table will be changed. If it partially fails, the table could be left without primary key constraint. - -*/ --- DropForeignKey -ALTER TABLE "FavoriteReview" DROP CONSTRAINT "FavoriteReview_reviewId_fkey"; - --- AlterTable -ALTER TABLE "FavoriteReview" ALTER COLUMN "reviewId" SET DATA TYPE TEXT; - --- AlterTable -ALTER TABLE "Review" DROP CONSTRAINT "Review_pkey", -ALTER COLUMN "id" DROP DEFAULT, -ALTER COLUMN "id" SET DATA TYPE TEXT, -ADD CONSTRAINT "Review_pkey" PRIMARY KEY ("id"); -DROP SEQUENCE "Review_id_seq"; - --- AddForeignKey -ALTER TABLE "FavoriteReview" ADD CONSTRAINT "FavoriteReview_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql b/src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql deleted file mode 100644 index af5102c..0000000 --- a/src/prisma/migrations/20240521101330_favorite_by_to_favorites/migration.sql +++ /dev/null @@ -1 +0,0 @@ --- This is an empty migration. \ No newline at end of file diff --git a/src/prisma/migrations/20240522152425_/migration.sql b/src/prisma/migrations/20240522152425_/migration.sql deleted file mode 100644 index 3689f56..0000000 --- a/src/prisma/migrations/20240522152425_/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `jobTitle` on the `User` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "User" DROP COLUMN "jobTitle"; From 969b53248bb4ac90393663b03f38c70730c66aed Mon Sep 17 00:00:00 2001 From: Enache Adelina Date: Fri, 24 May 2024 14:15:02 +0300 Subject: [PATCH 32/32] fix tests --- jest.config.json | 6 +- jest.e2e-config.json | 6 +- package.json | 17 - src/app/app.module.ts | 6 +- .../connections.controller.spec.ts | 3 + src/connections/connections.controller.ts | 4 +- src/connections/connections.service.spec.ts | 3 +- src/connections/connections.service.ts | 4 +- src/middlewares/logger.middleware.ts | 5 + src/reviews/reviews.controller.spec.ts | 11 + src/reviews/reviews.controller.ts | 2 +- src/reviews/reviews.e2e.spec.ts | 370 ++++++++++++++++++ src/reviews/reviews.service.spec.ts | 11 +- src/reviews/reviews.service.ts | 4 +- src/user/service/user.service.ts | 2 +- src/user/user.e2e.spec.ts | 314 --------------- {utils => src/utils}/image.ts | 0 utils/e2e-plugins.ts | 15 + 18 files changed, 437 insertions(+), 346 deletions(-) create mode 100644 src/reviews/reviews.e2e.spec.ts rename {utils => src/utils}/image.ts (100%) create mode 100644 utils/e2e-plugins.ts diff --git a/jest.config.json b/jest.config.json index 9264fde..a189430 100644 --- a/jest.config.json +++ b/jest.config.json @@ -3,7 +3,11 @@ "testEnvironment": "node", "testPathIgnorePatterns": [".*.e2e.spec.ts"], "transform": { - "^.+\\.[tj]s$": ["ts-jest", { "tsconfig": "/tsconfig.spec.json" }] + "^.+\\.[tj]s$": ["ts-jest", { "tsconfig": "/tsconfig.spec.json" }, + { "astTransformers": { + "before": ["./utils/e2e-plugin.ts"] + } + }] }, "moduleFileExtensions": ["ts", "js", "html"], "coverageDirectory": "./coverage-e2e" diff --git a/jest.e2e-config.json b/jest.e2e-config.json index 52e2072..ca4d133 100644 --- a/jest.e2e-config.json +++ b/jest.e2e-config.json @@ -4,7 +4,11 @@ "testEnvironment": "node", "testMatch": ["**/*.e2e.spec.ts"], "transform": { - "^.+\\.[tj]s$": ["ts-jest", { "tsconfig": "/tsconfig.spec.json" }] + "^.+\\.[tj]s$": ["ts-jest", { "tsconfig": "/tsconfig.spec.json" }, + { "astTransformers": { + "before": ["./utils/e2e-plugin.ts"] + } + }] }, "moduleFileExtensions": ["ts", "js", "html"], "coverageDirectory": "./coverage-e2e" diff --git a/package.json b/package.json index 7ff413f..2d4e85d 100644 --- a/package.json +++ b/package.json @@ -88,22 +88,5 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bf9ce0f..f675a40 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -9,9 +9,9 @@ import { AuthGuard } from '../auth/guard/auth/auth.guard'; import { PrismaModule } from '../prisma/prisma.module'; import { MailModule } from '../mail/mail.module'; import { ProviderModule } from '../provider/provider.module'; -import { ReviewsModule } from 'src/reviews/reviews.module'; -import { ConnectionsModule } from 'src/connections/connections.module'; -import { AppLoggerMiddleware } from 'src/middlewares/logger.middleware'; +import { ReviewsModule } from '../../src/reviews/reviews.module'; +import { ConnectionsModule } from '../../src/connections/connections.module'; +import { AppLoggerMiddleware } from '../../src/middlewares/logger.middleware'; import { CommonModule } from '../common/common.module'; @Module({ diff --git a/src/connections/connections.controller.spec.ts b/src/connections/connections.controller.spec.ts index 5634f24..0ad1b57 100644 --- a/src/connections/connections.controller.spec.ts +++ b/src/connections/connections.controller.spec.ts @@ -1,5 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConnectionsController } from './connections.controller'; +import { ConnectionsService } from './connections.service'; +import { PrismaService } from '../../src/prisma/prisma.service'; describe('ConnectionsController', () => { let controller: ConnectionsController; @@ -7,6 +9,7 @@ describe('ConnectionsController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ConnectionsController], + providers: [ConnectionsService, PrismaService], }).compile(); controller = module.get(ConnectionsController); diff --git a/src/connections/connections.controller.ts b/src/connections/connections.controller.ts index e2b67bf..f475ae5 100644 --- a/src/connections/connections.controller.ts +++ b/src/connections/connections.controller.ts @@ -1,5 +1,5 @@ import { Controller, Delete, Get, Param, Post } from '@nestjs/common'; -import { CurrentUser } from 'src/decorators/current-user.decorator'; +import { CurrentUser } from '../../src/decorators/current-user.decorator'; import { ConnectionsService } from './connections.service'; import { ConnectionDto } from './dto/Connection.dto'; import { @@ -8,7 +8,7 @@ import { ApiNotFoundResponse, } from '@nestjs/swagger'; import { User } from '@prisma/client'; -import { Public } from 'src/decorators/public.decorator'; +import { Public } from '../../src/decorators/public.decorator'; @ApiBearerAuth() @Controller('connections') diff --git a/src/connections/connections.service.spec.ts b/src/connections/connections.service.spec.ts index bf46498..72099b6 100644 --- a/src/connections/connections.service.spec.ts +++ b/src/connections/connections.service.spec.ts @@ -1,12 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConnectionsService } from './connections.service'; +import { PrismaService } from '../../src/prisma/prisma.service'; describe('ConnectionsService', () => { let service: ConnectionsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ConnectionsService], + providers: [ConnectionsService, PrismaService], }).compile(); service = module.get(ConnectionsService); diff --git a/src/connections/connections.service.ts b/src/connections/connections.service.ts index 2e11ca4..7db7afa 100644 --- a/src/connections/connections.service.ts +++ b/src/connections/connections.service.ts @@ -4,10 +4,10 @@ import { NotFoundException, } from '@nestjs/common'; import { AuthType, User } from '@prisma/client'; -import { PrismaService } from 'src/prisma/prisma.service'; +import { PrismaService } from '../../src/prisma/prisma.service'; import { ConnectionDto } from './dto/Connection.dto'; import { ApiTags } from '@nestjs/swagger'; -import { ProfileFetcherDelegator } from 'src/user/profile-fetcher/delegator.profile-fetcher'; +import { ProfileFetcherDelegator } from '../../src/user/profile-fetcher/delegator.profile-fetcher'; import { v4 } from 'uuid'; @Injectable() diff --git a/src/middlewares/logger.middleware.ts b/src/middlewares/logger.middleware.ts index 02da166..c42ea65 100644 --- a/src/middlewares/logger.middleware.ts +++ b/src/middlewares/logger.middleware.ts @@ -9,6 +9,11 @@ export class AppLoggerMiddleware implements NestMiddleware { use(request: Request, response: Response, next: NextFunction): void { const startAt = process.hrtime(); const { ip, method, originalUrl } = request; + + if (!request || !request.get) { + next(); + return; + } const userAgent = request.get('user-agent') || ''; const send = response.send; diff --git a/src/reviews/reviews.controller.spec.ts b/src/reviews/reviews.controller.spec.ts index e1cd836..8f17c07 100644 --- a/src/reviews/reviews.controller.spec.ts +++ b/src/reviews/reviews.controller.spec.ts @@ -1,5 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ReviewsController } from './reviews.controller'; +import { ReviewsService } from './reviews.service'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { REDIS_CLIENT } from '../../src/provider/redis.provider'; describe('ReviewsController', () => { let controller: ReviewsController; @@ -7,6 +10,14 @@ describe('ReviewsController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ReviewsController], + providers: [ + ReviewsService, + PrismaService, + { + provide: REDIS_CLIENT, + useValue: {}, + }, + ], }).compile(); controller = module.get(ReviewsController); diff --git a/src/reviews/reviews.controller.ts b/src/reviews/reviews.controller.ts index 807c027..27a33f1 100644 --- a/src/reviews/reviews.controller.ts +++ b/src/reviews/reviews.controller.ts @@ -16,7 +16,7 @@ import { ApiTags, } from '@nestjs/swagger'; import { ReviewDto } from './dto/reviews.dto'; -import { CurrentUser } from 'src/decorators/current-user.decorator'; +import { CurrentUser } from '../../src/decorators/current-user.decorator'; import { User } from '@prisma/client'; import { CreateReviewBodyDTO } from './dto/create-review.dto'; import { RatingDto } from './dto/rating.dto'; diff --git a/src/reviews/reviews.e2e.spec.ts b/src/reviews/reviews.e2e.spec.ts new file mode 100644 index 0000000..28feea3 --- /dev/null +++ b/src/reviews/reviews.e2e.spec.ts @@ -0,0 +1,370 @@ +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import { AppModule } from '../app/app.module'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthType } from '@prisma/client'; +import { ValidationPipe } from '@nestjs/common'; +import { MailService } from '../mail/mail.service'; +import { mockDeep } from 'jest-mock-extended'; +import { MailModule } from '../mail/mail.module'; +import { UserModule } from '../user/user.module'; +import { ReviewsModule } from './reviews.module'; + +describe('Reviws Controller Tests', () => { + let app: NestFastifyApplication; + let prisma: PrismaService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, UserModule, MailModule, ReviewsModule], + }) + .overrideProvider(MailService) + .useValue(mockDeep()) + .compile(); + app = moduleRef.createNestApplication( + new FastifyAdapter(), + ); + app.useGlobalPipes(new ValidationPipe()); + prisma = moduleRef.get(PrismaService); + + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + beforeEach(async () => { + await prisma.user.deleteMany(); + + await prisma.user.create({ + data: { + id: '1', + email: 'johndoe@example.com', + name: 'John Doe', + isEmailVerified: true, + authType: AuthType.EMAIL, + }, + }); + }); + + describe('review tests', () => { + beforeEach(async () => { + await prisma.user.create({ + data: { + id: '2', + email: 'janedoe@example.com', + name: 'Jane Doe', + isEmailVerified: true, + authType: AuthType.EMAIL, + }, + }); + + await prisma.review.createMany({ + data: [ + { + postedToId: '2', + postedById: '1', + professionalism: 5, + reliability: 5, + communication: 5, + comment: 'Something', + }, + { + postedToId: '2', + postedById: '1', + professionalism: 5, + reliability: 5, + communication: 5, + comment: 'lala', + }, + ], + }); + + await prisma.review.create({ + data: { + postedToId: '1', + postedById: '2', + professionalism: 5, + reliability: 5, + communication: 5, + comment: 'lala', + }, + }); + }); + + it('should not be able to rate a user that does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 5, + reliability: 5, + communication: 5, + comment: 'lala', + }, + postedToId: '3', + }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toBe('User not found'); + }); + + it('should throw validation error if professionalism rating is not provided', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + reliability: 5, + communication: 5, + comment: 'lala', + }, + postedToId: '2', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should throw validation error if reliability rating is not provided', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 5, + communication: 5, + comment: 'lala', + }, + postedToId: '2', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should throw validation error if communication rating is not provided', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 2, + reliability: 5, + comment: 'lala', + }, + postedToId: '2', + }, + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should not allow a user to rate themselves', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 5, + reliability: 5, + communication: 5, + comment: 'lala', + }, + postedToId: '1', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().message).toBe('You cannot rate yourself'); + }); + + it('should not be able to rate anything lesser than 1', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 5, + reliability: 5, + communication: 0, + comment: 'lala', + }, + postedToId: '2', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should not be able to rate anything greater than 5', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 5, + reliability: 5, + communication: 6, + comment: 'lala', + }, + postedToId: '2', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should be able to rate another user', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 5, + reliability: 5, + communication: 5, + comment: 'lala', + }, + postedToId: '2', + }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json().postedToId).toBe('2'); + expect(response.json().postedBy.id).toBe('1'); + expect(response.json().professionalism).toBe(5); + expect(response.json().reliability).toBe(5); + expect(response.json().communication).toBe(5); + + const rating = await prisma.review.findUnique({ + where: { + id: response.json().id, + }, + }); + expect(rating).toBeDefined(); + expect(rating.postedToId).toBe('2'); + expect(rating.postedById).toBe('1'); + expect(rating.professionalism).toBe(5); + expect(rating.reliability).toBe(5); + expect(rating.communication).toBe(5); + }); + + it('should be able to rate another user - anon', async () => { + const response = await app.inject({ + method: 'POST', + url: '/reviews', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + review: { + professionalism: 5, + reliability: 5, + communication: 5, + comment: 'lala', + anonymous: true, + }, + postedToId: '2', + }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json().postedToId).toBe('2'); + expect(response.json().professionalism).toBe(5); + expect(response.json().reliability).toBe(5); + expect(response.json().communication).toBe(5); + expect(response.json().isAnonymous).toBe(true); + expect(response.json().postedBy).toBe(undefined); + + const rating = await prisma.review.findUnique({ + where: { + id: response.json().id, + }, + }); + expect(rating).toBeDefined(); + expect(rating.postedToId).toBe('2'); + expect(rating.postedById).toBe('1'); + expect(rating.professionalism).toBe(5); + expect(rating.reliability).toBe(5); + expect(rating.communication).toBe(5); + expect(rating.anonymous).toBe(true); + }); + + it('should be able to get the ratings of another user', async () => { + const response = await app.inject({ + method: 'GET', + url: '/reviews/2', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + }); + + expect(response.statusCode).toBe(200); + + const rating = response.json()[0]; + expect(rating.professionalism).toBe(5); + expect(rating.reliability).toBe(5); + expect(rating.communication).toBe(5); + expect(rating.comment).toBe('Something'); + }); + + it('should be able to get average ratings of another user', async () => { + const response = await app.inject({ + method: 'GET', + url: '/reviews/avg-rating/2', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().professionalism).toBe(5); + expect(response.json().reliability).toBe(5); + expect(response.json().communication).toBe(5); + }); + }); + afterAll(async () => { + try { + await prisma.user.deleteMany(); + await prisma.review.deleteMany(); + await app.close(); + } catch (error) { + console.log('error', error); + } + }); +}); diff --git a/src/reviews/reviews.service.spec.ts b/src/reviews/reviews.service.spec.ts index 2f2b32e..a862789 100644 --- a/src/reviews/reviews.service.spec.ts +++ b/src/reviews/reviews.service.spec.ts @@ -1,12 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ReviewsService } from './reviews.service'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { REDIS_CLIENT } from '../../src/provider/redis.provider'; describe('ReviewsService', () => { let service: ReviewsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ReviewsService], + providers: [ + ReviewsService, + PrismaService, + { + provide: REDIS_CLIENT, + useValue: {}, + }, + ], }).compile(); service = module.get(ReviewsService); diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index 049ea5d..7c3c5f1 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -6,9 +6,9 @@ import { NotFoundException, } from '@nestjs/common'; import { User, Review, FavoriteReview, ReviewState } from '@prisma/client'; -import { PrismaService } from 'src/prisma/prisma.service'; +import { PrismaService } from '../../src/prisma/prisma.service'; import { ReviewDto } from './dto/reviews.dto'; -import { REDIS_CLIENT } from 'src/provider/redis.provider'; +import { REDIS_CLIENT } from '../../src/provider/redis.provider'; import Redis from 'ioredis'; import { CreateReviewDto } from './dto/create-review.dto'; import { RatingDto } from './dto/rating.dto'; diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 7cceb42..e23fa79 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -13,7 +13,7 @@ import { S3_CLIENT } from '../../provider/s3.provider'; import { REDIS_CLIENT } from '../../provider/redis.provider'; import { Redis } from 'ioredis'; -import { getMimeType } from 'utils/image'; +import { getMimeType } from '../../utils/image'; @Injectable() export class UserService { diff --git a/src/user/user.e2e.spec.ts b/src/user/user.e2e.spec.ts index b10e220..4754e51 100644 --- a/src/user/user.e2e.spec.ts +++ b/src/user/user.e2e.spec.ts @@ -117,320 +117,6 @@ describe('User Controller Tests', () => { expect(updatedUser.name).toBe('John Doe'); }); - describe('review tests', () => { - beforeEach(async () => { - await prisma.user.create({ - data: { - id: '2', - email: 'janedoe@example.com', - name: 'Jane Doe', - isEmailVerified: true, - authType: AuthType.EMAIL, - }, - }); - - await prisma.review.createMany({ - data: [ - { - postedToId: '2', - postedById: '1', - professionalism: 5, - reliability: 5, - communication: 5, - comment: 'Something', - }, - { - postedToId: '2', - postedById: '1', - professionalism: 5, - reliability: 5, - communication: 5, - }, - ], - }); - - await prisma.review.create({ - data: { - postedToId: '1', - postedById: '2', - professionalism: 5, - reliability: 5, - communication: 5, - }, - }); - }); - - it('should not be able to rate a user that does not exist', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/3', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 5, - reliability: 5, - communication: 5, - }, - }); - - expect(response.statusCode).toBe(404); - expect(response.json().message).toBe('User not found'); - }); - - it('should throw validation error if professionalism rating is not provided', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - reliability: 5, - communication: 5, - }, - }); - - expect(response.statusCode).toBe(400); - }); - - it('should throw validation error if reliability rating is not provided', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 5, - communication: 5, - }, - }); - - expect(response.statusCode).toBe(400); - }); - - it('should throw validation error if communication rating is not provided', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 5, - reliability: 5, - }, - }); - - expect(response.statusCode).toBe(400); - }); - - it('should not allow a user to rate themselves', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/1', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 5, - reliability: 5, - communication: 5, - }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json().message).toBe('You cannot rate yourself'); - }); - - it('should not be able to rate anything lesser than 1', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 0, - reliability: 0, - communication: 0, - }, - }); - - expect(response.statusCode).toBe(400); - }); - - it('should not be able to rate anything greater than 5', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 6, - reliability: 6, - communication: 6, - }, - }); - - expect(response.statusCode).toBe(400); - }); - - it('should be able to rate another user', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 5, - reliability: 5, - communication: 5, - }, - }); - - expect(response.statusCode).toBe(201); - expect(response.json().postedToId).toBe('2'); - expect(response.json().postedById).toBe('1'); - expect(response.json().professionalism).toBe(5); - expect(response.json().reliability).toBe(5); - expect(response.json().communication).toBe(5); - - const rating = await prisma.review.findUnique({ - where: { - id: response.json().id, - }, - }); - expect(rating).toBeDefined(); - expect(rating.postedToId).toBe('2'); - expect(rating.postedById).toBe('1'); - expect(rating.professionalism).toBe(5); - expect(rating.reliability).toBe(5); - expect(rating.communication).toBe(5); - }); - - it('should be able to rate another user', async () => { - const response = await app.inject({ - method: 'POST', - url: '/user/rate/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - payload: { - professionalism: 5, - reliability: 5, - communication: 5, - anonymous: true, - }, - }); - - expect(response.statusCode).toBe(201); - expect(response.json().postedToId).toBe('2'); - expect(response.json().postedById).toBe(null); - expect(response.json().professionalism).toBe(5); - expect(response.json().reliability).toBe(5); - expect(response.json().communication).toBe(5); - expect(response.json().anonymous).toBe(true); - - const rating = await prisma.review.findUnique({ - where: { - id: response.json().id, - }, - }); - expect(rating).toBeDefined(); - expect(rating.postedToId).toBe('2'); - expect(rating.postedById).toBe(null); - expect(rating.professionalism).toBe(5); - expect(rating.reliability).toBe(5); - expect(rating.communication).toBe(5); - expect(rating.anonymous).toBe(true); - }); - - it('should be able to get the ratings of the current user', async () => { - const response = await app.inject({ - method: 'GET', - url: '/user/ratings/self', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toHaveLength(1); - - const rating = response.json()[0]; - expect(rating.userName).toBe('Jane Doe'); - expect(rating.professionalism).toBe(5); - expect(rating.reliability).toBe(5); - expect(rating.communication).toBe(5); - }); - - it('should be able to get the ratings of another user', async () => { - const response = await app.inject({ - method: 'GET', - url: '/user/ratings/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toHaveLength(2); - - const rating = response.json()[0]; - expect(rating.userName).toBe('John Doe'); - expect(rating.professionalism).toBe(5); - expect(rating.reliability).toBe(5); - expect(rating.communication).toBe(5); - expect(rating.comment).toBe('Something'); - }); - - it('should be able to get average ratings of another user', async () => { - const response = await app.inject({ - method: 'GET', - url: '/user/avg-rating/2', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json().professionalism).toBe(5); - expect(response.json().reliability).toBe(5); - expect(response.json().communication).toBe(5); - expect(response.json().overall).toBe(5); - }); - - it('should be able to get average rating of self', async () => { - const response = await app.inject({ - method: 'GET', - url: '/user/avg-rating/self', - headers: { - 'x-e2e-user-email': 'johndoe@example.com', - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json().professionalism).toBe(5); - expect(response.json().reliability).toBe(5); - expect(response.json().communication).toBe(5); - expect(response.json().overall).toBe(5); - }); - }); - - it('should be able to search for a user', async () => { - const response = await app.inject({ - method: 'GET', - url: '/user/search/John', - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toHaveLength(1); - }); - afterAll(async () => { try { await prisma.user.deleteMany(); diff --git a/utils/image.ts b/src/utils/image.ts similarity index 100% rename from utils/image.ts rename to src/utils/image.ts diff --git a/utils/e2e-plugins.ts b/utils/e2e-plugins.ts new file mode 100644 index 0000000..1b3795f --- /dev/null +++ b/utils/e2e-plugins.ts @@ -0,0 +1,15 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const transformer = require('@nestjs/swagger/plugin'); + +module.exports.name = 'nestjs-swagger-transformer'; +// you should change the version number anytime you change the configuration below - otherwise, jest will not detect changes +module.exports.version = 1; + +module.exports.factory = (cs) => { + return transformer.before( + { + // @nestjs/swagger/plugin options (can be empty) + }, + cs.program, // "cs.tsCompiler.program" for older versions of Jest (<= v27) + ); +};