diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9f84477 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["onboarded"] +} diff --git a/src/auth/auth.e2e.spec.ts b/src/auth/auth.e2e.spec.ts index 901122d..eb9fade 100644 --- a/src/auth/auth.e2e.spec.ts +++ b/src/auth/auth.e2e.spec.ts @@ -41,44 +41,24 @@ describe('Auth Controller Tests', () => { it('should be able to sign up using email', async () => { const response = await app.inject({ method: 'POST', - url: '/auth/sign-up', + url: '/auth/email', payload: { email: 'jane@example.com', }, }); expect(response.statusCode).toEqual(201); - expect(response.json().email).toEqual('jane@example.com'); - }); - - it('should be able to sign in using email', async () => { - await prisma.user.create({ - data: { - email: 'jane@example.com', - isEmailVerified: true, - authType: AuthType.EMAIL, - }, + expect(response.json()).toEqual({ + status: 'success', }); - - const response = await app.inject({ - method: 'POST', - url: '/auth/sign-in', - payload: { - email: 'jane@example.com', - }, - }); - - expect(response.statusCode).toEqual(201); - expect(response.json().email).toEqual('jane@example.com'); }); it('should send verification code to email on sign up', async () => { await app.inject({ method: 'POST', - url: '/auth/sign-up', + url: '/auth/email', payload: { email: 'jane@example.com', - password: 'Password123', }, }); @@ -123,10 +103,9 @@ describe('Auth Controller Tests', () => { // Sign up await app.inject({ method: 'POST', - url: '/auth/sign-up', + url: '/auth/email', payload: { email: 'jane@example.com', - password: 'Password123', }, }); diff --git a/src/auth/controller/auth.controller.ts b/src/auth/controller/auth.controller.ts index 6c280cb..fb2bff2 100644 --- a/src/auth/controller/auth.controller.ts +++ b/src/auth/controller/auth.controller.ts @@ -20,12 +20,10 @@ import { Public } from '../../decorators/public.decorator'; import { FacebookOAuthStrategyFactory } from '../../oauth/factory/facebook/facebook-strategy.factory'; import { LinkedInOAuthStrategyFactory } from '../../oauth/factory/linkedin/linkedin-strategy.factory'; import { AppleOAuthStrategyFactory } from '../../oauth/factory/apple/apple-strategy.factory'; -import { SignupDto } from '../dto/signup.dto'; -import { SigninDto } from '../dto/signin.dto'; +import { UserDetailsDto } from '../dto/user-details.dto'; import { EmailVerificationDto } from '../dto/email-verification.dto'; import { ApiBadRequestResponse, - ApiConflictResponse, ApiCreatedResponse, ApiNoContentResponse, ApiNotFoundResponse, @@ -37,6 +35,7 @@ import { LowercasePipe } from '../../common/pipes/lowercase.pipe'; import { GithubOAuthStrategyFactory } from '../../oauth/factory/github/github-strategy.factory'; import { CurrentUser } from '../../decorators/current-user.decorator'; import { SocialAccountType, User } from '@prisma/client'; +import { BypassOnboardingCheck } from '../../decorators/bypass-onboarding.decorator'; @Controller('auth') @ApiTags('Auth Controller') @@ -313,29 +312,10 @@ export class AuthController { } @Public() - @Post('sign-up') + @Post('email') @ApiOperation({ - summary: 'Sign up', - description: 'Sign up with email', - }) - @ApiCreatedResponse({ - description: 'User signed up successfully', - }) - @ApiConflictResponse({ - description: 'User with this email already exists', - }) - async signUp(@Body() dto: SignupDto) { - return await this.authService.signUp(dto); - } - - @Public() - @Post('sign-in') - @ApiOperation({ - summary: 'Sign in', - description: 'Sign in with email', - }) - @ApiNotFoundResponse({ - description: 'User not found', + summary: 'Sign in or sign up with email', + description: 'Sign in or sign up with email', }) @ApiCreatedResponse({ description: 'User signed in successfully', @@ -347,8 +327,8 @@ export class AuthController { }, }, }) - async signIn(@Body() dto: SigninDto) { - return await this.authService.signIn(dto); + async sendVerificationCode(@Body() dto: UserDetailsDto) { + return await this.authService.sendVerificationCode(dto); } @Public() @@ -396,9 +376,10 @@ export class AuthController { }, }) async verifyEmail(@Body() dto: EmailVerificationDto) { - return await this.authService.verifyEmail(dto.email, dto.code); + return await this.authService.verifyEmail(dto); } + @BypassOnboardingCheck() @Get('/social-accounts') async getSocialAccounts(@CurrentUser() user: User) { return this.authService.getSocialAccounts(user.id); diff --git a/src/auth/dto/signup.dto.ts b/src/auth/dto/signup.dto.ts deleted file mode 100644 index 0c7e4db..0000000 --- a/src/auth/dto/signup.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsEmail } from 'class-validator'; - -export class SignupDto { - @IsEmail() - @Transform(({ value }) => value.toLowerCase()) - @ApiProperty({ - name: 'email', - description: 'User email. Must be a valid email address.', - required: true, - type: String, - example: 'johndoe@example.com', - }) - email: string; -} diff --git a/src/auth/dto/signin.dto.ts b/src/auth/dto/user-details.dto.ts similarity index 92% rename from src/auth/dto/signin.dto.ts rename to src/auth/dto/user-details.dto.ts index 20d4d6e..096c122 100644 --- a/src/auth/dto/signin.dto.ts +++ b/src/auth/dto/user-details.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail } from 'class-validator'; -export class SigninDto { +export class UserDetailsDto { @IsEmail() @Transform(({ value }) => value.toLowerCase()) @ApiProperty({ diff --git a/src/auth/guard/auth/auth.guard.ts b/src/auth/guard/auth/auth.guard.ts index fa294ca..aa738d3 100644 --- a/src/auth/guard/auth/auth.guard.ts +++ b/src/auth/guard/auth/auth.guard.ts @@ -3,6 +3,7 @@ import { ExecutionContext, ForbiddenException, Injectable, + UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; @@ -10,6 +11,7 @@ import { Reflector } from '@nestjs/core'; import { IS_PUBLIC_KEY } from '../../../decorators/public.decorator'; import { PrismaService } from '../../../prisma/prisma.service'; import { User } from '@prisma/client'; +import { ONBOARDING_BYPASSED } from '../../../decorators/bypass-onboarding.decorator'; const X_E2E_USER_EMAIL = 'x-e2e-user-email'; @@ -74,6 +76,17 @@ export class AuthGuard implements CanActivate { } } + const onboardingBypassed = + this.reflector.getAllAndOverride(ONBOARDING_BYPASSED, [ + context.getHandler(), + context.getClass(), + ]) ?? false; + + // If the onboarding is not finished, we throw an UnauthorizedException. + if (!onboardingBypassed && !user.onboarded) { + throw new UnauthorizedException('Onboarding not finished'); + } + // We attach the user to the request object. request['user'] = user; return true; diff --git a/src/auth/service/auth.service.ts b/src/auth/service/auth.service.ts index 2f9463f..0149465 100644 --- a/src/auth/service/auth.service.ts +++ b/src/auth/service/auth.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -8,9 +7,11 @@ import { import { PrismaService } from '../../prisma/prisma.service'; import { JwtService } from '@nestjs/jwt'; import { AuthType, SocialAccountType, User } from '@prisma/client'; -import { SignupDto } from '../dto/signup.dto'; +import { UserDetailsDto } from '../dto/user-details.dto'; import { MailService } from '../../mail/mail.service'; -import { SigninDto } from '../dto/signin.dto'; +import { EmailVerificationDto } from '../dto/email-verification.dto'; +import { plainToClass } from 'class-transformer'; +import UserResponseDto from '../../user/dto/user-response.dto'; @Injectable() export class AuthService { @@ -20,35 +21,12 @@ export class AuthService { private mailService: MailService, ) {} - async signUp(dto: SignupDto) { - const user = await this.createUserIfNotExists( - dto.email, - AuthType.EMAIL, - null, - null, - true, - ); + async sendVerificationCode(dto: UserDetailsDto) { + await this.createUserIfNotExists(dto.email, AuthType.EMAIL, null, null); - return user; - } - - async signIn(dto: SigninDto) { - const user = await this.prisma.user.findUnique({ - where: { - email: dto.email, - }, - select: { - isEmailVerified: true, - email: true, - }, - }); - - if (!user) { - throw new NotFoundException('User not found'); - } - - await this.sendEmailVerificationCode(dto.email); - return user; + return { + status: 'success', + }; } async handleGoogleOAuthLogin(req: any) { @@ -61,7 +39,6 @@ export class AuthService { AuthType.GOOGLE, name, profilePictureUrl, - false, ); const token = await this.generateToken(user); @@ -98,7 +75,6 @@ export class AuthService { AuthType.FACEBOOK, displayName, profilePictureUrl, - false, ); await this.connectSocialPlatform( SocialAccountType.FACEBOOK, @@ -137,7 +113,6 @@ export class AuthService { AuthType.LINKEDIN, displayName, picture, - false, ); await this.connectSocialPlatform( SocialAccountType.LINKEDIN, @@ -161,7 +136,6 @@ export class AuthService { AuthType.APPLE, displayName, null, - false, ); const token = await this.generateToken(user); @@ -194,7 +168,6 @@ export class AuthService { AuthType.GITHUB, name, avatar_url, - false, ); await this.connectSocialPlatform(SocialAccountType.GITHUB, user.id, req); } @@ -216,7 +189,9 @@ export class AuthService { await this.sendEmailVerificationCode(email); } - async verifyEmail(email: string, code: string) { + async verifyEmail(dto: EmailVerificationDto) { + const { email, code } = dto; + const verificationCode = await this.prisma.verificationCode.findUnique({ where: { email, @@ -235,35 +210,42 @@ export class AuthService { throw new BadRequestException('Code expired'); } - const user = await this.prisma.user.update({ + const user = await this.prisma.user.findUnique({ where: { email, }, - data: { - isEmailVerified: true, - }, - select: { - id: true, - email: true, - name: true, - profilePictureUrl: true, - authType: true, - isEmailVerified: true, - }, }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const updatedUser = plainToClass( + UserResponseDto, + await this.prisma.user.update({ + where: { + email, + }, + data: { + isEmailVerified: true, + }, + }), + ); + await this.prisma.verificationCode.delete({ where: { email, }, }); - await this.mailService.sendEmailVerifiedEmail(email); + // We send the email verified email only if the user is was not verified before + if (!user.isEmailVerified) + await this.mailService.sendEmailVerifiedEmail(email); - const token = await this.generateToken(user); + const token = await this.generateToken(updatedUser); return { - ...user, + ...updatedUser, token, }; } @@ -273,14 +255,11 @@ export class AuthService { authType: AuthType, name?: string, profilePictureUrl?: string, - throwErrorIfUserExists?: boolean, ) { email = email.toLowerCase(); let user = await this.findUserByEmail(email); - if (user && throwErrorIfUserExists) { - throw new ConflictException('User already exists'); - } + // We need to create the user if it doesn't exist yet if (!user) { user = await this.prisma.user.create({ @@ -289,27 +268,31 @@ export class AuthService { name: name, profilePictureUrl: profilePictureUrl, authType, - isEmailVerified: authType !== AuthType.EMAIL, + isEmailVerified: authType !== AuthType.EMAIL, // If the user signs up with OAuth, we consider the email as verified settings: { create: {}, }, }, - select: { - id: true, - email: true, - name: true, - profilePictureUrl: true, - authType: true, - isEmailVerified: true, + }); + } else if (!user.onboarded) { + // And if it exists, we need to update the user data + // only if the user is not onboarded yet + user = await this.prisma.user.update({ + where: { + email, + }, + data: { + name, + profilePictureUrl, }, }); + } - await this.sendEmailVerificationCode(email); - } else if (!user.isEmailVerified) { + if (!user.isEmailVerified) { await this.sendEmailVerificationCode(email); } - return user; + return plainToClass(UserResponseDto, user); } private async generateToken(user: Partial) { @@ -327,14 +310,6 @@ export class AuthService { where: { email, }, - select: { - id: true, - email: true, - name: true, - profilePictureUrl: true, - authType: true, - isEmailVerified: true, - }, }); } diff --git a/src/decorators/bypass-onboarding.decorator.ts b/src/decorators/bypass-onboarding.decorator.ts new file mode 100644 index 0000000..7818a18 --- /dev/null +++ b/src/decorators/bypass-onboarding.decorator.ts @@ -0,0 +1,11 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * There are some routes that we want the users to be able to access + * even before they are done with the onboarding process. This decorator + * is used to mark those routes. + */ + +export const ONBOARDING_BYPASSED = 'onboarding_bypassed'; +export const BypassOnboardingCheck = () => + SetMetadata(ONBOARDING_BYPASSED, true); diff --git a/src/prisma/migrations/20240618125311_add_user_onboard_status/migration.sql b/src/prisma/migrations/20240618125311_add_user_onboard_status/migration.sql new file mode 100644 index 0000000..1675ee8 --- /dev/null +++ b/src/prisma/migrations/20240618125311_add_user_onboard_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "onboarded" BOOLEAN NOT NULL DEFAULT false; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index b9d135a..44beaec 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -30,6 +30,7 @@ model User { email String? @unique name String? location String? + onboarded Boolean @default(false) profilePictureUrl String? socialAccounts SocialAccount[] authType AuthType diff --git a/src/reviews/reviews.e2e.spec.ts b/src/reviews/reviews.e2e.spec.ts index ea26288..a724e53 100644 --- a/src/reviews/reviews.e2e.spec.ts +++ b/src/reviews/reviews.e2e.spec.ts @@ -51,6 +51,7 @@ describe('Reviws Controller Tests', () => { email: 'johndoe@example.com', name: 'John Doe', isEmailVerified: true, + onboarded: true, authType: AuthType.EMAIL, }, }); diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index 95f5b2a..3e22156 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -4,6 +4,7 @@ import { Controller, Delete, Get, + Patch, Put, Req, Res, @@ -29,6 +30,7 @@ import { LinkedInOAuthStrategyFactory } from '../../oauth/factory/linkedin/linke import { AuthGuard } from '@nestjs/passport'; import { UpdateUserSettingsDto } from '../dto/update-user-settings.dto'; import { UserSettingsDto } from '../dto/user-settings.dto'; +import { BypassOnboardingCheck } from '../../decorators/bypass-onboarding.decorator'; @Controller('user') @ApiBearerAuth() @@ -40,6 +42,7 @@ export class UserController { ) {} @Get() + @BypassOnboardingCheck() @ApiOperation({ summary: 'Get current user', description: 'Get the currently logged in user', @@ -55,6 +58,7 @@ export class UserController { return this.userService.getSelf(user); } + @BypassOnboardingCheck() @Put() @ApiOperation({ summary: 'Update current user', @@ -74,6 +78,7 @@ export class UserController { return this.userService.updateSelf(user, dto); } + @BypassOnboardingCheck() @Put('/profile-picture') @ApiOperation({ summary: 'Upload profile picture encoded in base64', @@ -143,6 +148,12 @@ export class UserController { return this.userService.updateSettings(user.id, data); } + @Patch('onboard') + @BypassOnboardingCheck() + async onboardUser(@CurrentUser() user: User) { + return this.userService.onboardUser(user); + } + /** * Delete the entire user account */ diff --git a/src/user/dto/user-response.dto.ts b/src/user/dto/user-response.dto.ts new file mode 100644 index 0000000..3b98c59 --- /dev/null +++ b/src/user/dto/user-response.dto.ts @@ -0,0 +1,32 @@ +import { AuthType } from '@prisma/client'; +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export default class UserResponseDto { + @Expose() + id: string; + + @Expose() + email: string; + + @Expose() + name: string; + + @Expose() + location: string; + + @Expose() + onboarded: boolean; + + @Expose() + profilePictureUrl: string; + + @Expose() + authType: AuthType; + + @Expose() + isEmailVerified: boolean; + + @Expose() + headline: string; +} diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index b267090..84f858d 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Inject, Injectable, InternalServerErrorException, @@ -15,6 +16,8 @@ import { Redis } from 'ioredis'; import { v4 } from 'uuid'; import { getMimeType } from '../../utils/image'; import { UpdateUserSettingsDto } from '../dto/update-user-settings.dto'; +import { plainToClass } from 'class-transformer'; +import UserResponseDto from '../dto/user-response.dto'; @Injectable() export class UserService { @@ -27,18 +30,21 @@ export class UserService { ) {} async getSelf(user: User) { - return user; + return plainToClass(UserResponseDto, user); } async updateSelf(user: User, dto: UpdateUserDto) { - return await this.prisma.user.update({ - where: { id: user.id }, - data: { - name: dto.name, - headline: dto.headline, - location: dto.location, - }, - }); + return plainToClass( + UserResponseDto, + await this.prisma.user.update({ + where: { id: user.id }, + data: { + name: dto.name, + headline: dto.headline, + location: dto.location, + }, + }), + ); } async updateProfilePicture(user: User, file: string) { @@ -65,12 +71,15 @@ export class UserService { await this.s3.send(putObjectRequest); this.logger.log('Profile picture uploaded'); - return await this.prisma.user.update({ - where: { id: user.id }, - data: { - profilePictureUrl: `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`, - }, - }); + return plainToClass( + UserResponseDto, + await this.prisma.user.update({ + where: { id: user.id }, + data: { + profilePictureUrl: `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`, + }, + }), + ); } catch (err) { throw new InternalServerErrorException( 'Failed to upload profile picture', @@ -138,6 +147,20 @@ export class UserService { } } + async onboardUser(user: User) { + if (user.onboarded) { + throw new ConflictException('User has already been onboarded'); + } + + return plainToClass( + UserResponseDto, + await this.prisma.user.update({ + where: { id: user.id }, + data: { onboarded: true }, + }), + ); + } + updateSettings(id: string, data: UpdateUserSettingsDto) { return this.prisma.userSettings.update({ where: { diff --git a/src/user/user.e2e.spec.ts b/src/user/user.e2e.spec.ts index f221814..b294fc4 100644 --- a/src/user/user.e2e.spec.ts +++ b/src/user/user.e2e.spec.ts @@ -44,6 +44,7 @@ describe('User Controller Tests', () => { name: 'John Doe', isEmailVerified: true, authType: AuthType.EMAIL, + onboarded: true, }, }); }); @@ -53,6 +54,37 @@ describe('User Controller Tests', () => { expect(prisma).toBeDefined(); }); + it('should be able to update itself if onboarding is not finished', async () => { + await prisma.user.update({ + where: { + email: 'johndoe@example.com', + }, + data: { + onboarded: false, + }, + }); + + const response = await app.inject({ + method: 'PUT', + url: '/user', + headers: { + 'x-e2e-user-email': 'johndoe@example.com', + }, + payload: { + name: 'Jane Doe', + }, + }); + + const updatedUser = await prisma.user.findUnique({ + where: { + email: 'johndoe@example.com', + }, + }); + expect(updatedUser.name).toBe('Jane Doe'); + + expect(response.statusCode).toBe(200); + }); + it('should be able to get the current user', async () => { const response = await app.inject({ method: 'GET',