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/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 + } + } + ] } } 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 469523b..f675a40 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'; @@ -9,6 +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 { CommonModule } from '../common/common.module'; @Module({ @@ -23,6 +26,8 @@ import { CommonModule } from '../common/common.module'; PrismaModule, MailModule, ProviderModule, + ReviewsModule, + ConnectionsModule, ], controllers: [AppController], providers: [ @@ -32,4 +37,8 @@ import { CommonModule } from '../common/common.module'; }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(AppLoggerMiddleware).forRoutes('*'); + } +} diff --git a/src/connections/connections.controller.spec.ts b/src/connections/connections.controller.spec.ts new file mode 100644 index 0000000..0ad1b57 --- /dev/null +++ b/src/connections/connections.controller.spec.ts @@ -0,0 +1,21 @@ +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; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ConnectionsController], + providers: [ConnectionsService, PrismaService], + }).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..f475ae5 --- /dev/null +++ b/src/connections/connections.controller.ts @@ -0,0 +1,97 @@ +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.addConnection(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.removeConnection(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. + */ + @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..72099b6 --- /dev/null +++ b/src/connections/connections.service.spec.ts @@ -0,0 +1,19 @@ +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, PrismaService], + }).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..7db7afa --- /dev/null +++ b/src/connections/connections.service.ts @@ -0,0 +1,182 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +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') +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 convertConnectionToDto( + 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, + isEmailVerified: user.isEmailVerified, + joinedAt: user.joinedAt, + authType: user.authType, + 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.convertConnectionToDto(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.convertConnectionToDto(c.following)); + } + + async addConnection(currentUserId: User['id'], userId: User['id']) { + const connection = await this.prisma.connection.upsert({ + where: { + followerId_followingId: { + followerId: currentUserId, + followingId: userId, + }, + }, + update: {}, + create: { + followerId: currentUserId, + followingId: userId, + }, + include: { + following: { + include: this.includeWithUserConnection(currentUserId), + }, + }, + }); + + return this.convertConnectionToDto(connection.following); + } + + async removeConnection(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.convertConnectionToDto(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: { include: this.includeWithUserConnection() }, + }, + }); + if (socialAccount) return this.convertConnectionToDto(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.convertConnectionToDto(newUser); + } +} diff --git a/src/connections/dto/Connection.dto.ts b/src/connections/dto/Connection.dto.ts new file mode 100644 index 0000000..3e5389d --- /dev/null +++ b/src/connections/dto/Connection.dto.ts @@ -0,0 +1,22 @@ +import { AuthType } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsDate, IsEnum } from 'class-validator'; + +export class ConnectionDto { + name?: string; + headline?: string; + profilePictureUrl?: string; + isEmailVerified: boolean; + id: string; + email?: string; + @Type(() => Date) + @IsDate() + joinedAt: Date; + + reviewsCount: number; + connectionsCount: number; + isConnection: boolean; + + @IsEnum(AuthType) + authType: AuthType; +} 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/middlewares/logger.middleware.ts b/src/middlewares/logger.middleware.ts new file mode 100644 index 0000000..c42ea65 --- /dev/null +++ b/src/middlewares/logger.middleware.ts @@ -0,0 +1,45 @@ +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; + + if (!request || !request.get) { + next(); + return; + } + 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(); + } +} 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/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/schema.prisma b/src/prisma/schema.prisma index 7ab64bd..5b56235 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -25,20 +25,20 @@ 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") - ratingsPosted Rating[] @relation("ratingsGivenToOtherUsers") - ratingsReceived Rating[] @relation("ratingsRecievedFromOtherUsers") - 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 +63,38 @@ model SocialAccount { @@index([userId, platform, profileUrl]) } -model Rating { - id Int @id @default(autoincrement()) +enum ReviewState { + PENDING + BLOCKED + APPROVED +} + +model Review { + id String @id @default(cuid()) // 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) - 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) + favorites 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 { diff --git a/src/user/dto/rating.dto.ts b/src/reviews/dto/create-review.dto.ts similarity index 77% rename from src/user/dto/rating.dto.ts rename to src/reviews/dto/create-review.dto.ts index 2968f09..d1590ef 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,22 @@ export class RatingDto { }) anonymous?: boolean; } + +export class CreateReviewBodyDTO { + @IsString() + @ApiProperty({ + name: 'postedToId', + description: 'The Id of the reviewed user.', + example: 'asdjas20', + required: true, + }) + 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..77af469 --- /dev/null +++ b/src/reviews/dto/reviews.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ReviewState } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsDate, IsEnum, IsOptional, ValidateNested } from 'class-validator'; + +export class PostedByDTO { + name?: string; + + profilePictureUrl?: string; + isEmailVerified?: boolean; + id: string; +} + +export class ReviewDto { + id: string; + 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; + isFavorite: boolean; + + @Type(() => PostedByDTO) + @IsOptional() + @ValidateNested() + postedBy?: PostedByDTO; + + @IsEnum(ReviewState) + state: ReviewState; +} diff --git a/src/reviews/dto/update-review.dto.ts b/src/reviews/dto/update-review.dto.ts new file mode 100644 index 0000000..fb0517a --- /dev/null +++ b/src/reviews/dto/update-review.dto.ts @@ -0,0 +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; + @IsBoolean() + @IsOptional() + anonymous?: boolean; +} diff --git a/src/reviews/reviews.controller.spec.ts b/src/reviews/reviews.controller.spec.ts new file mode 100644 index 0000000..8f17c07 --- /dev/null +++ b/src/reviews/reviews.controller.spec.ts @@ -0,0 +1,29 @@ +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; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReviewsController], + providers: [ + ReviewsService, + PrismaService, + { + provide: REDIS_CLIENT, + useValue: {}, + }, + ], + }).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..27a33f1 --- /dev/null +++ b/src/reviews/reviews.controller.ts @@ -0,0 +1,139 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from '@nestjs/common'; +import { ReviewsService } from './reviews.service'; +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 { + 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 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 + */ + @Get('/:userId') + async getReviews( + @CurrentUser() user: User, + @Param('userId') postedToId: string, + ): Promise> { + return this.reviewsService.getUserReviews(user, postedToId); + } + + /** + * + * Create a review + */ + @Post() + 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); + } + + /** + * 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); + } + + /** + * + * 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.reviewsService.updateReview(user, reviewId, data); + } +} 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.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..a862789 --- /dev/null +++ b/src/reviews/reviews.service.spec.ts @@ -0,0 +1,27 @@ +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, + PrismaService, + { + provide: REDIS_CLIENT, + useValue: {}, + }, + ], + }).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..7c3c5f1 --- /dev/null +++ b/src/reviews/reviews.service.ts @@ -0,0 +1,303 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, +} 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 { 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 { + constructor( + private readonly prisma: PrismaService, + @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 } & { favorites: FavoriteReview[] }, + 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.anonymous, + professionalism: review.professionalism, + reliability: review.reliability, + communication: review.communication, + comment: review.comment, + createdAt: review.createdAt, + isOwnReview: review.postedById == currentUserId, + // hide "postedBy" for anonymous reviews + 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, + }, + }, + }; + } + + 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: this.includeWithReview(user.id), + orderBy: { + createdAt: 'desc', + }, + }); + + return ratings + .map((review) => this.transformReview(review, user.id)) + .filter((r) => r.state === 'APPROVED' || r.isOwnReview); + } + + 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: user.id, + professionalism: ratingDto.professionalism, + reliability: ratingDto.reliability, + communication: ratingDto.communication, + comment: ratingDto.comment, + anonymous: ratingDto.anonymous, + state: ReviewState.APPROVED, + }, + include: this.includeWithReview(user.id), + }); + + // 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; + } + + 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 { + await 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); + } + + // 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']) { + 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 updateReview( + 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.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); + } + + 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)); + } +} 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/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 91f6015..9de45e4 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -3,40 +3,25 @@ import { Body, Controller, Get, - Param, - Post, 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, - ApiCreatedResponse, ApiInternalServerErrorResponse, - ApiNotFoundResponse, ApiOkResponse, ApiOperation, 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 { Multer } from 'multer'; +import { userProperties } from '../../schemas/user.properties'; + import { Public } from '../../decorators/public.decorator'; import { Request, Response } from 'express'; import { LinkedInOAuthStrategyFactory } from '../../oauth/factory/linkedin/linkedin-strategy.factory'; @@ -87,9 +72,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({ @@ -102,101 +86,20 @@ 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); } - @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() @@ -217,91 +120,4 @@ 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') - @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({ - 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 17ecf5e..e23fa79 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -4,21 +4,16 @@ import { Injectable, InternalServerErrorException, Logger, - NotFoundException, } from '@nestjs/common'; -import { AuthType, SocialAccountType, User } from '@prisma/client'; +import { 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'; -import { v4 } from 'uuid'; + +import { getMimeType } from '../../utils/image'; @Injectable() export class UserService { @@ -44,12 +39,22 @@ 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', + ); + 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 { @@ -63,110 +68,12 @@ export class UserService { }, }); } catch (err) { - this.logger.error(err); throw new InternalServerErrorException( 'Failed to upload profile picture', ); } } - 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, - }, - }); - - return ratings.map((review) => ({ - userName: review.postedBy ? review.postedBy.name : 'Anonymous', - profilePictureUrl: review.postedBy?.profilePictureUrl, - professionalism: review.professionalism, - reliability: review.reliability, - communication: review.communication, - createdOn: review.createdAt.toISOString(), - comment: !self ? review.comment : undefined, - })); - } - - async searchUsers(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: { - id: true, - email: true, - name: true, - joinedAt: true, - isEmailVerified: true, - jobTitle: true, - profilePictureUrl: true, - followings: true, - _count: { - select: { - followings: true, - ratingsReceived: true, - }, - }, - }, - }); - 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 @@ -199,7 +106,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 }, }), @@ -226,90 +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; - } - - 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 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, - }; - } } diff --git a/src/user/user.e2e.spec.ts b/src/user/user.e2e.spec.ts index 994bdad..4754e51 100644 --- a/src/user/user.e2e.spec.ts +++ b/src/user/user.e2e.spec.ts @@ -117,324 +117,10 @@ describe('User Controller Tests', () => { expect(updatedUser.name).toBe('John Doe'); }); - describe('Rating tests', () => { - beforeEach(async () => { - await prisma.user.create({ - data: { - id: '2', - email: 'janedoe@example.com', - name: 'Jane Doe', - isEmailVerified: true, - authType: AuthType.EMAIL, - }, - }); - - await prisma.rating.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.rating.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.rating.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.rating.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(); - await prisma.rating.deleteMany(); + await prisma.review.deleteMany(); await app.close(); } catch (error) { console.log('error', error); diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..515e8d0 --- /dev/null +++ b/src/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]; +}; 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) + ); +};