From fa11a4aaefdcc4599278fd76ff70d4bec097dfc4 Mon Sep 17 00:00:00 2001 From: Ellie Re'em Date: Fri, 21 Jun 2024 14:47:47 +0100 Subject: [PATCH 1/7] refactor formatuserObject with new formatPartnerAdminObject function --- src/utils/serialize.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts index 46971b33..c30ec474 100644 --- a/src/utils/serialize.ts +++ b/src/utils/serialize.ts @@ -1,3 +1,4 @@ +import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { IPartnerFeature } from 'src/partner-feature/partner-feature.interface'; import { IPartner } from 'src/partner/partner.interface'; @@ -41,6 +42,16 @@ export const formatCourseUserObject = (courseUser: CourseUserEntity) => { }; }; +export const formatPartnerAdminObjects = (partnerAdminObject: PartnerAdminEntity) => { + return { + id: partnerAdminObject.id, + active: partnerAdminObject.active, + createdAt: partnerAdminObject.createdAt, + updatedAt: partnerAdminObject.updatedAt, + partner: partnerAdminObject.partner ? formatPartnerObject(partnerAdminObject.partner) : null, + }; +}; + export const formatPartnerAccessObjects = (partnerAccessObjects: PartnerAccessEntity[]) => { return partnerAccessObjects.map((partnerAccess) => { return { @@ -97,13 +108,7 @@ export const formatUserObject = (userObject: UserEntity): GetUserDto => { ? formatPartnerAccessObjects(userObject.partnerAccess) : null, partnerAdmin: userObject.partnerAdmin - ? { - id: userObject.partnerAdmin.id, - active: userObject.partnerAdmin.active, - createdAt: userObject.partnerAdmin.createdAt, - updatedAt: userObject.partnerAdmin.updatedAt, - partner: formatPartnerObject(userObject.partnerAdmin.partner), - } + ? formatPartnerAdminObjects(userObject.partnerAdmin) : null, courses: userObject.courseUser ? formatCourseUserObjects(userObject.courseUser) : [], subscriptions: @@ -137,6 +142,7 @@ export const formatGetUsersObject = (userObject: UserEntity): GetUserDto => { : null, } : {}), + ...(userObject.partnerAdmin ? formatPartnerAdminObjects(userObject.partnerAdmin) : {}), }; }; From cb9902124fe7bb46a9e7a741a02079bd85772bc5 Mon Sep 17 00:00:00 2001 From: Ellie Re'em Date: Fri, 21 Jun 2024 15:10:13 +0100 Subject: [PATCH 2/7] refactor getUsers service method to return UserEntity and fix types --- src/user/user.controller.ts | 4 +++- src/user/user.service.spec.ts | 15 ++------------- src/user/user.service.ts | 20 +++++--------------- 3 files changed, 10 insertions(+), 29 deletions(-) diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 737dd70b..d87e067a 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -14,6 +14,7 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs import { Request } from 'express'; import { UserEntity } from 'src/entities/user.entity'; import { SuperAdminAuthGuard } from 'src/partner-admin/super-admin-auth.guard'; +import { formatUserObject } from 'src/utils/serialize'; import { FirebaseAuthGuard } from '../firebase/firebase-auth.guard'; import { ControllerDecorator } from '../utils/controller.decorator'; import { CreateUserDto } from './dtos/create-user.dto'; @@ -109,7 +110,8 @@ export class UserController { const { include, fields, limit, ...userQuery } = query.searchCriteria ? JSON.parse(query.searchCriteria) : { include: [], fields: [], limit: undefined }; - return await this.userService.getUsers(userQuery, include, fields, limit); + const users = await this.userService.getUsers(userQuery, include || [], fields, limit); + return users.map((u) => formatUserObject(u)); } // Use only if users have not been added to mailchimp due to e.g. an ongoing bug diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index d17bc6e5..840f2c3d 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -515,22 +515,11 @@ describe('UserService', () => { // TODO - Extend getUser tests. At the moment, this is only used by super admins describe('getUsers', () => { it('getUsers', async () => { - const { - subscriptionUser, - therapySession, - partnerAdmin, - partnerAccess, - contactPermission, - serviceEmailsPermission, - courseUser, - eventLog, - ...userBase - } = mockUserEntity; jest .spyOn(repo, 'find') .mockImplementationOnce(async () => [{ ...mockUserEntity, email: 'a@b.com' }]); - const users = await service.getUsers({ email: 'a@b.com' }, {}, [], 10); - expect(users).toEqual([{ user: { ...userBase, email: 'a@b.com' }, partnerAccesses: [] }]); + const users = await service.getUsers({ email: 'a@b.com' }, [], [], 10); + expect(users).toEqual([{ ...mockUserEntity, email: 'a@b.com' }]); }); }); }); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index cc0b5b44..f70cd1f6 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -18,7 +18,7 @@ import { And, ILike, IsNull, Not, Raw, Repository } from 'typeorm'; import { deleteCypressCrispProfiles } from '../api/crisp/crisp-api'; import { AuthService } from '../auth/auth.service'; import { PartnerAccessService, basePartnerAccess } from '../partner-access/partner-access.service'; -import { formatGetUsersObject, formatUserObject } from '../utils/serialize'; +import { formatUserObject } from '../utils/serialize'; import { generateRandomString } from '../utils/utils'; import { CreateUserDto } from './dtos/create-user.dto'; import { GetUserDto } from './dtos/get-user.dto'; @@ -265,20 +265,12 @@ export class UserService { partnerAccess?: { userId: string; featureTherapy: boolean; active: boolean }; partnerAdmin?: { partnerAdminId: string }; }, - relations: { - partner?: boolean; - partnerAccess?: boolean; - partnerAdmin?: boolean; - courseUser?: boolean; - subscriptionUser?: boolean; - therapySession?: boolean; - eventLog?: boolean; - }, + relations: string[], fields: Array, limit: number, - ): Promise { + ): Promise { const users = await this.userRepository.find({ - relations: relations, + relations, where: { ...(filters.email && { email: ILike(`%${filters.email}%`) }), ...(filters.partnerAccess && { @@ -305,9 +297,7 @@ export class UserService { }, ...(limit && { take: limit }), }); - - const usersDto = users.map((user) => formatGetUsersObject(user)); - return usersDto; + return users; } // Static bulk upload function to be used in specific cases From be8b0060dd99e6f4cd7e0fd9327496df2a036a5f Mon Sep 17 00:00:00 2001 From: Ellie Re'em Date: Wed, 26 Jun 2024 13:18:27 +0100 Subject: [PATCH 3/7] refactor: improve logging by attaching requestUserId and adding types --- src/logger/logger.ts | 6 ++++-- src/logger/logging.interceptor.ts | 5 ++++- src/logger/utils.ts | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 src/logger/utils.ts diff --git a/src/logger/logger.ts b/src/logger/logger.ts index 4dfa4edd..aa4b2a9d 100644 --- a/src/logger/logger.ts +++ b/src/logger/logger.ts @@ -2,6 +2,7 @@ import { ConsoleLogger } from '@nestjs/common'; import Rollbar from 'rollbar'; import { FIREBASE_ERRORS } from 'src/utils/errors'; import { isProduction, rollbarEnv, rollbarToken } from '../utils/constants'; +import { ErrorLog } from './utils'; export class Logger extends ConsoleLogger { private rollbar?: Rollbar; @@ -12,12 +13,13 @@ export class Logger extends ConsoleLogger { this.initialiseRollbar(); } - error(message: string, trace?: string): void { + error(message: string | ErrorLog, trace?: string): void { if (this.rollbar) { this.rollbar.error(message); } + const formattedMessage = typeof message === 'string' ? message : JSON.stringify(message); - const taggedMessage = `[error] ${message}`; + const taggedMessage = `[error] ${formattedMessage}`; super.error(taggedMessage, trace); } diff --git a/src/logger/logging.interceptor.ts b/src/logger/logging.interceptor.ts index bf4e0eb4..a36e6168 100644 --- a/src/logger/logging.interceptor.ts +++ b/src/logger/logging.interceptor.ts @@ -11,7 +11,10 @@ export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const req = context.switchToHttp().getRequest(); - const commonMessage = `${req.method} "${req.originalUrl}" for ${req.ip}`; + // @ts-ignore + const userId = req?.userEntity?.id; + + const commonMessage = `${req.method} "${req.originalUrl}" (IP address: ${req.ip}, requestUserId: ${userId})`; this.logger.log(`Started ${commonMessage}`); diff --git a/src/logger/utils.ts b/src/logger/utils.ts new file mode 100644 index 00000000..ec532d27 --- /dev/null +++ b/src/logger/utils.ts @@ -0,0 +1,16 @@ +import { HttpStatus } from '@nestjs/common'; + +export type ErrorLog = { + error: string; + status: HttpStatus; + errorMessage?: string; + userId?: string; + requestUserId?: string; +}; + +export type EventLog = { + event: string; + fields?: string[]; + userId?: string; + requestUserId?: string; +}; From 1158561f044cab86be80b5745c0e4e05dfa5ac24 Mon Sep 17 00:00:00 2001 From: Ellie Re'em Date: Wed, 26 Jun 2024 13:19:03 +0100 Subject: [PATCH 4/7] feat: add logger.warn to authguards --- src/firebase/firebase-auth.guard.ts | 28 +++++++++++++++++++ src/logger/constants.ts | 9 ++++++ src/partner-admin/partner-admin-auth.guard.ts | 22 +++++++++++++++ src/partner-admin/super-admin-auth.guard.ts | 15 ++++++++-- 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/logger/constants.ts diff --git a/src/firebase/firebase-auth.guard.ts b/src/firebase/firebase-auth.guard.ts index 2c7d66a0..9037b155 100644 --- a/src/firebase/firebase-auth.guard.ts +++ b/src/firebase/firebase-auth.guard.ts @@ -4,9 +4,16 @@ import { HttpException, HttpStatus, Injectable, + Logger, UnauthorizedException, } from '@nestjs/common'; import { Request } from 'express'; +import { + AUTH_GUARD_MISSING_HEADER, + AUTH_GUARD_PARSING_ERROR, + AUTH_GUARD_TOKEN_EXPIRED, + AUTH_GUARD_USER_NOT_FOUND, +} from 'src/logger/constants'; import { FIREBASE_ERRORS } from 'src/utils/errors'; import { AuthService } from '../auth/auth.service'; import { UserService } from '../user/user.service'; @@ -14,6 +21,8 @@ import { IFirebaseUser } from './firebase-user.interface'; @Injectable() export class FirebaseAuthGuard implements CanActivate { + private readonly logger = new Logger('FirebaseAuthGuard'); + constructor( private authService: AuthService, private userService: UserService, @@ -25,6 +34,10 @@ export class FirebaseAuthGuard implements CanActivate { const { authorization } = request.headers; if (!authorization) { + this.logger.warn({ + error: AUTH_GUARD_MISSING_HEADER, + errorMessage: `FirebaseAuthGuard: Authorisation failed for ${request.originalUrl}`, + }); throw new UnauthorizedException('Unauthorized: missing required Authorization token'); } @@ -33,8 +46,18 @@ export class FirebaseAuthGuard implements CanActivate { user = await this.authService.parseAuth(authorization); } catch (error) { if (error.code === 'auth/id-token-expired') { + this.logger.warn({ + error: AUTH_GUARD_TOKEN_EXPIRED, + errorMessage: `FireabaseAuthGuard: Authorisation failed for ${request.originalUrl}`, + status: HttpStatus.UNAUTHORIZED, + }); throw new HttpException(FIREBASE_ERRORS.ID_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); } + this.logger.warn({ + error: AUTH_GUARD_PARSING_ERROR, + errorMessage: `FirebaseAuthGuard: Authorisation failed for ${request.originalUrl}`, + status: HttpStatus.INTERNAL_SERVER_ERROR, + }); throw new HttpException( `FirebaseAuthGuard - Error parsing firebase user: ${error}`, @@ -50,6 +73,11 @@ export class FirebaseAuthGuard implements CanActivate { request['userEntity'] = userEntity; } catch (error) { if (error.message === 'USER NOT FOUND') { + this.logger.warn({ + error: AUTH_GUARD_USER_NOT_FOUND, + errorMessage: `FirebaseAuthGuard: Authorisation failed for ${request.originalUrl}`, + status: HttpStatus.INTERNAL_SERVER_ERROR, + }); throw new HttpException( `FirebaseAuthGuard - Firebase user exists but user no record in bloom database for ${user.email}: ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, diff --git a/src/logger/constants.ts b/src/logger/constants.ts new file mode 100644 index 00000000..647a56bb --- /dev/null +++ b/src/logger/constants.ts @@ -0,0 +1,9 @@ +// AUTH ERRORS +export const AUTH_GUARD_TOKEN_EXPIRED = 'AUTH_GUARD_TOKEN_EXPIRED'; +export const AUTH_GUARD_PARSING_ERROR = 'AUTH_GUARD_PARSING_ERROR'; +export const AUTH_GUARD_MISSING_HEADER = 'AUTH_GUARD_MISSING_HEADER'; +export const AUTH_GUARD_USER_NOT_FOUND = 'AUTH_GUARD_USER_NOT_FOUND'; + +// ERROR LOGS +export const UPDATE_FIREBASE_USER_EMAIL_ALREADY_EXISTS = + 'UPDATE_FIREBASE_USER_EMAIL_ALREADY_EXISTS'; diff --git a/src/partner-admin/partner-admin-auth.guard.ts b/src/partner-admin/partner-admin-auth.guard.ts index c36e6420..0fe1166f 100644 --- a/src/partner-admin/partner-admin-auth.guard.ts +++ b/src/partner-admin/partner-admin-auth.guard.ts @@ -4,16 +4,24 @@ import { HttpException, HttpStatus, Injectable, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Request } from 'express'; import { UserEntity } from 'src/entities/user.entity'; +import { + AUTH_GUARD_MISSING_HEADER, + AUTH_GUARD_PARSING_ERROR, + AUTH_GUARD_TOKEN_EXPIRED, +} from 'src/logger/constants'; import { FIREBASE_ERRORS } from 'src/utils/errors'; import { Repository } from 'typeorm'; import { AuthService } from '../auth/auth.service'; @Injectable() export class PartnerAdminAuthGuard implements CanActivate { + private readonly logger = new Logger('SuperAdminAuthGuard'); + constructor( private authService: AuthService, @InjectRepository(UserEntity) private userRepository: Repository, @@ -23,6 +31,10 @@ export class PartnerAdminAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); const { authorization } = request.headers; if (!authorization) { + this.logger.warn({ + error: AUTH_GUARD_MISSING_HEADER, + errorMessage: `PartnerAdminAuthGuard: Authorisation failed for ${request.originalUrl}`, + }); return false; } let userUid; @@ -32,9 +44,18 @@ export class PartnerAdminAuthGuard implements CanActivate { userUid = uid; } catch (error) { if (error.code === 'auth/id-token-expired') { + this.logger.warn({ + error: AUTH_GUARD_TOKEN_EXPIRED, + errorMessage: `PartnerAdminAuthGuard: Authorisation failed for ${request.originalUrl}`, + }); throw new HttpException(FIREBASE_ERRORS.ID_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); } + this.logger.warn({ + error: AUTH_GUARD_PARSING_ERROR, + errorMessage: `PartnerAdminAuthGuard: Authorisation failed for ${request.originalUrl}`, + }); + throw new HttpException( `PartnerAdminAuthGuard - Error parsing firebase user: ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, @@ -56,6 +77,7 @@ export class PartnerAdminAuthGuard implements CanActivate { request['partnerId'] = user.partnerAdmin.partner.id; // TODO is this the best way to be handling user details request['partnerAdminId'] = user.partnerAdmin.id; + request['userEntity'] = user; if (user.partnerAdmin.id) return true; return false; diff --git a/src/partner-admin/super-admin-auth.guard.ts b/src/partner-admin/super-admin-auth.guard.ts index 4f4de5d4..7cc253d2 100644 --- a/src/partner-admin/super-admin-auth.guard.ts +++ b/src/partner-admin/super-admin-auth.guard.ts @@ -4,16 +4,20 @@ import { HttpException, HttpStatus, Injectable, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Request } from 'express'; import { UserEntity } from 'src/entities/user.entity'; +import { AUTH_GUARD_PARSING_ERROR, AUTH_GUARD_TOKEN_EXPIRED } from 'src/logger/constants'; import { FIREBASE_ERRORS } from 'src/utils/errors'; import { Repository } from 'typeorm'; import { AuthService } from '../auth/auth.service'; @Injectable() export class SuperAdminAuthGuard implements CanActivate { + private readonly logger = new Logger('SuperAdminAuthGuard'); + constructor( private authService: AuthService, @InjectRepository(UserEntity) private userRepository: Repository, @@ -36,9 +40,16 @@ export class SuperAdminAuthGuard implements CanActivate { userUid = uid; } catch (error) { if (error.code === 'auth/id-token-expired') { + this.logger.warn({ + error: AUTH_GUARD_TOKEN_EXPIRED, + errorMessage: `Authorisation failed for ${request.originalUrl}`, + }); throw new HttpException(FIREBASE_ERRORS.ID_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); } - + this.logger.warn({ + error: AUTH_GUARD_PARSING_ERROR, + errorMessage: `Authorisation failed for ${request.originalUrl}`, + }); throw new HttpException( `SuperAdminAuthGuard - Error parsing firebase user: ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, @@ -46,7 +57,7 @@ export class SuperAdminAuthGuard implements CanActivate { } try { const user = await this.userRepository.findOneBy({ firebaseUid: userUid }); - + request['userEntity'] = user; return !!user.isSuperAdmin && user.email.indexOf('@chayn.co') !== -1; } catch (error) { throw new HttpException( From 4317115fce7db2538bfa3f9c86fc2e04f7c220e0 Mon Sep 17 00:00:00 2001 From: Ellie Re'em Date: Wed, 26 Jun 2024 13:21:07 +0100 Subject: [PATCH 5/7] refactor: improve logging in logger interceptor --- src/logger/logging.interceptor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/logger/logging.interceptor.ts b/src/logger/logging.interceptor.ts index a36e6168..3a0b890e 100644 --- a/src/logger/logging.interceptor.ts +++ b/src/logger/logging.interceptor.ts @@ -11,7 +11,7 @@ export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const req = context.switchToHttp().getRequest(); - // @ts-ignore + //@ts-expect-error: userEntity is modified in authGuard const userId = req?.userEntity?.id; const commonMessage = `${req.method} "${req.originalUrl}" (IP address: ${req.ip}, requestUserId: ${userId})`; From 75d568f56a7d62f81f296ac7f27de30a4860c21c Mon Sep 17 00:00:00 2001 From: Ellie Re'em Date: Thu, 27 Jun 2024 11:04:20 +0100 Subject: [PATCH 6/7] fix: encrypt staging database transactions --- src/typeorm.config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/typeorm.config.ts b/src/typeorm.config.ts index 91d1fadd..1c256b31 100644 --- a/src/typeorm.config.ts +++ b/src/typeorm.config.ts @@ -51,6 +51,8 @@ config(); const configService = new ConfigService(); const isProduction = configService.get('NODE_ENV') === 'production'; +const isStaging = configService.get('NODE_ENV') === 'staging'; + const { host, port, user, password, database } = PostgressConnectionStringParser.parse( configService.get('DATABASE_URL'), ); @@ -114,9 +116,9 @@ export const dataSourceOptions = { BloomBackend1718728423454, ], subscribers: [], - ssl: isProduction, + ssl: isProduction || isStaging, extra: { - ssl: isProduction ? { rejectUnauthorized: false } : null, + ssl: isProduction || isStaging ? { rejectUnauthorized: false } : null, }, }; From f1031b939ca7ef6b749f408a9f4d13e2a8ac01da Mon Sep 17 00:00:00 2001 From: Ellie Re'em Date: Mon, 1 Jul 2024 12:08:59 +0100 Subject: [PATCH 7/7] feat: endpoint for updating users profile details --- src/auth/auth.service.ts | 38 +++++++++++++++++++ src/firebase/firebase-auth.guard.ts | 17 +++------ src/logger/constants.ts | 9 ----- src/partner-admin/partner-admin-auth.guard.ts | 15 +++----- src/partner-admin/super-admin-auth.guard.ts | 9 +++-- src/user/dtos/update-user.dto.ts | 7 +++- src/user/user.controller.ts | 11 +++++- src/user/user.service.spec.ts | 24 +++++++----- src/user/user.service.ts | 20 +++++++++- src/utils/errors.ts | 11 ++++++ src/utils/logs.ts | 4 ++ 11 files changed, 117 insertions(+), 48 deletions(-) delete mode 100644 src/logger/constants.ts create mode 100644 src/utils/logs.ts diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index b319d946..d4bd59e3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -73,6 +73,44 @@ export class AuthService { } } + public async updateFirebaseUserEmail(firebaseUid: string, newEmail: string) { + try { + const firebaseUser = await this.firebase.admin.auth().updateUser(firebaseUid, { + email: newEmail, + }); + return firebaseUser; + } catch (err) { + const errorCode = err.code; + + if (errorCode === 'auth/invalid-email') { + this.logger.warn({ + error: FIREBASE_ERRORS.UPDATE_USER_INVALID_EMAIL, + status: HttpStatus.BAD_REQUEST, + }); + throw new HttpException(FIREBASE_ERRORS.UPDATE_USER_INVALID_EMAIL, HttpStatus.BAD_REQUEST); + } else if ( + errorCode === 'auth/email-already-in-use' || + errorCode === 'auth/email-already-exists' + ) { + this.logger.warn({ + error: FIREBASE_ERRORS.UPDATE_USER_ALREADY_EXISTS, + status: HttpStatus.BAD_REQUEST, + }); + throw new HttpException(FIREBASE_ERRORS.UPDATE_USER_ALREADY_EXISTS, HttpStatus.BAD_REQUEST); + } else { + this.logger.warn({ + error: FIREBASE_ERRORS.UPDATE_USER_FIREBASE_ERROR, + errorMessage: errorCode, + status: HttpStatus.INTERNAL_SERVER_ERROR, + }); + throw new HttpException( + FIREBASE_ERRORS.CREATE_USER_FIREBASE_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + public async getFirebaseUser(email: string) { const firebaseUser = await this.firebase.admin.auth().getUserByEmail(email); return firebaseUser; diff --git a/src/firebase/firebase-auth.guard.ts b/src/firebase/firebase-auth.guard.ts index 9037b155..7a3d9921 100644 --- a/src/firebase/firebase-auth.guard.ts +++ b/src/firebase/firebase-auth.guard.ts @@ -8,13 +8,8 @@ import { UnauthorizedException, } from '@nestjs/common'; import { Request } from 'express'; -import { - AUTH_GUARD_MISSING_HEADER, - AUTH_GUARD_PARSING_ERROR, - AUTH_GUARD_TOKEN_EXPIRED, - AUTH_GUARD_USER_NOT_FOUND, -} from 'src/logger/constants'; -import { FIREBASE_ERRORS } from 'src/utils/errors'; + +import { AUTH_GUARD_ERRORS, FIREBASE_ERRORS } from 'src/utils/errors'; import { AuthService } from '../auth/auth.service'; import { UserService } from '../user/user.service'; import { IFirebaseUser } from './firebase-user.interface'; @@ -35,7 +30,7 @@ export class FirebaseAuthGuard implements CanActivate { if (!authorization) { this.logger.warn({ - error: AUTH_GUARD_MISSING_HEADER, + error: AUTH_GUARD_ERRORS.MISSING_HEADER, errorMessage: `FirebaseAuthGuard: Authorisation failed for ${request.originalUrl}`, }); throw new UnauthorizedException('Unauthorized: missing required Authorization token'); @@ -47,14 +42,14 @@ export class FirebaseAuthGuard implements CanActivate { } catch (error) { if (error.code === 'auth/id-token-expired') { this.logger.warn({ - error: AUTH_GUARD_TOKEN_EXPIRED, + error: AUTH_GUARD_ERRORS.TOKEN_EXPIRED, errorMessage: `FireabaseAuthGuard: Authorisation failed for ${request.originalUrl}`, status: HttpStatus.UNAUTHORIZED, }); throw new HttpException(FIREBASE_ERRORS.ID_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); } this.logger.warn({ - error: AUTH_GUARD_PARSING_ERROR, + error: AUTH_GUARD_ERRORS.PARSING_ERROR, errorMessage: `FirebaseAuthGuard: Authorisation failed for ${request.originalUrl}`, status: HttpStatus.INTERNAL_SERVER_ERROR, }); @@ -74,7 +69,7 @@ export class FirebaseAuthGuard implements CanActivate { } catch (error) { if (error.message === 'USER NOT FOUND') { this.logger.warn({ - error: AUTH_GUARD_USER_NOT_FOUND, + error: AUTH_GUARD_ERRORS.USER_NOT_FOUND, errorMessage: `FirebaseAuthGuard: Authorisation failed for ${request.originalUrl}`, status: HttpStatus.INTERNAL_SERVER_ERROR, }); diff --git a/src/logger/constants.ts b/src/logger/constants.ts deleted file mode 100644 index 647a56bb..00000000 --- a/src/logger/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -// AUTH ERRORS -export const AUTH_GUARD_TOKEN_EXPIRED = 'AUTH_GUARD_TOKEN_EXPIRED'; -export const AUTH_GUARD_PARSING_ERROR = 'AUTH_GUARD_PARSING_ERROR'; -export const AUTH_GUARD_MISSING_HEADER = 'AUTH_GUARD_MISSING_HEADER'; -export const AUTH_GUARD_USER_NOT_FOUND = 'AUTH_GUARD_USER_NOT_FOUND'; - -// ERROR LOGS -export const UPDATE_FIREBASE_USER_EMAIL_ALREADY_EXISTS = - 'UPDATE_FIREBASE_USER_EMAIL_ALREADY_EXISTS'; diff --git a/src/partner-admin/partner-admin-auth.guard.ts b/src/partner-admin/partner-admin-auth.guard.ts index 0fe1166f..7dd4488b 100644 --- a/src/partner-admin/partner-admin-auth.guard.ts +++ b/src/partner-admin/partner-admin-auth.guard.ts @@ -9,12 +9,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Request } from 'express'; import { UserEntity } from 'src/entities/user.entity'; -import { - AUTH_GUARD_MISSING_HEADER, - AUTH_GUARD_PARSING_ERROR, - AUTH_GUARD_TOKEN_EXPIRED, -} from 'src/logger/constants'; -import { FIREBASE_ERRORS } from 'src/utils/errors'; +import { AUTH_GUARD_ERRORS } from 'src/utils/errors'; import { Repository } from 'typeorm'; import { AuthService } from '../auth/auth.service'; @@ -32,7 +27,7 @@ export class PartnerAdminAuthGuard implements CanActivate { const { authorization } = request.headers; if (!authorization) { this.logger.warn({ - error: AUTH_GUARD_MISSING_HEADER, + error: AUTH_GUARD_ERRORS.MISSING_HEADER, errorMessage: `PartnerAdminAuthGuard: Authorisation failed for ${request.originalUrl}`, }); return false; @@ -45,14 +40,14 @@ export class PartnerAdminAuthGuard implements CanActivate { } catch (error) { if (error.code === 'auth/id-token-expired') { this.logger.warn({ - error: AUTH_GUARD_TOKEN_EXPIRED, + error: AUTH_GUARD_ERRORS.TOKEN_EXPIRED, errorMessage: `PartnerAdminAuthGuard: Authorisation failed for ${request.originalUrl}`, }); - throw new HttpException(FIREBASE_ERRORS.ID_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); + throw new HttpException(AUTH_GUARD_ERRORS.TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); } this.logger.warn({ - error: AUTH_GUARD_PARSING_ERROR, + error: AUTH_GUARD_ERRORS.PARSING_ERROR, errorMessage: `PartnerAdminAuthGuard: Authorisation failed for ${request.originalUrl}`, }); diff --git a/src/partner-admin/super-admin-auth.guard.ts b/src/partner-admin/super-admin-auth.guard.ts index 7cc253d2..4219d8e4 100644 --- a/src/partner-admin/super-admin-auth.guard.ts +++ b/src/partner-admin/super-admin-auth.guard.ts @@ -9,8 +9,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Request } from 'express'; import { UserEntity } from 'src/entities/user.entity'; -import { AUTH_GUARD_PARSING_ERROR, AUTH_GUARD_TOKEN_EXPIRED } from 'src/logger/constants'; -import { FIREBASE_ERRORS } from 'src/utils/errors'; +import { AUTH_GUARD_ERRORS, FIREBASE_ERRORS } from 'src/utils/errors'; import { Repository } from 'typeorm'; import { AuthService } from '../auth/auth.service'; @@ -41,14 +40,16 @@ export class SuperAdminAuthGuard implements CanActivate { } catch (error) { if (error.code === 'auth/id-token-expired') { this.logger.warn({ - error: AUTH_GUARD_TOKEN_EXPIRED, + error: AUTH_GUARD_ERRORS.TOKEN_EXPIRED, errorMessage: `Authorisation failed for ${request.originalUrl}`, + status: HttpStatus.UNAUTHORIZED, }); throw new HttpException(FIREBASE_ERRORS.ID_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); } this.logger.warn({ - error: AUTH_GUARD_PARSING_ERROR, + error: AUTH_GUARD_ERRORS.PARSING_ERROR, errorMessage: `Authorisation failed for ${request.originalUrl}`, + status: HttpStatus.INTERNAL_SERVER_ERROR, }); throw new HttpException( `SuperAdminAuthGuard - Error parsing firebase user: ${error}`, diff --git a/src/user/dtos/update-user.dto.ts b/src/user/dtos/update-user.dto.ts index 6a946a29..0cf94fdb 100644 --- a/src/user/dtos/update-user.dto.ts +++ b/src/user/dtos/update-user.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsDate, IsEmail, IsOptional, IsString } from 'class-validator'; import { EMAIL_REMINDERS_FREQUENCY } from '../../utils/constants'; export class UpdateUserDto { @@ -32,4 +32,9 @@ export class UpdateUserDto { @IsOptional() @ApiProperty({ type: 'date' }) lastActiveAt: Date; + + @IsEmail({}) + @IsOptional() + @ApiProperty({ type: 'email' }) + email: string; } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index d87e067a..a1b4bfa8 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -46,7 +46,7 @@ export class UserController { @UseGuards(FirebaseAuthGuard) async getUserByFirebaseId(@Req() req: Request): Promise { const user = req['user']; - this.userService.updateUser({ lastActiveAt: new Date() }, user); + this.userService.updateUser({ lastActiveAt: new Date() }, user.id); return user; } @@ -100,7 +100,14 @@ export class UserController { @Patch() @UseGuards(FirebaseAuthGuard) async updateUser(@Body() updateUserDto: UpdateUserDto, @Req() req: Request) { - return await this.userService.updateUser(updateUserDto, req['user'] as GetUserDto); + return await this.userService.updateUser(updateUserDto, req['user'].user.id); + } + + @ApiBearerAuth() + @Patch('/admin/:id') + @UseGuards(SuperAdminAuthGuard) + async adminUpdateUser(@Param() { id }, @Body() updateUserDto: UpdateUserDto) { + return await this.userService.updateUser(updateUserDto, id); } @ApiBearerAuth() diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 840f2c3d..08552d90 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -48,6 +48,7 @@ const updateUserDto: Partial = { contactPermission: true, serviceEmailsPermission: false, signUpLanguage: 'en', + email: 'newemail@chayn.co', }; const mockSubscriptionUserServiceMethods = {}; @@ -279,26 +280,31 @@ describe('UserService', () => { describe('updateUser', () => { it('when supplied a firebase user dto, it should return a user', async () => { const repoSaveSpy = jest.spyOn(repo, 'save'); + const authServiceUpdateEmailSpy = jest.spyOn(mockAuthService, 'updateFirebaseUserEmail'); - const user = await service.updateUser(updateUserDto, { user: mockUserEntity }); - expect(user.name).toBe('new name'); - expect(user.email).toBe('user@email.com'); + const user = await service.updateUser(updateUserDto, mockUserEntity.id); + expect(user.name).toBe(updateUserDto.name); + expect(user.email).toBe(updateUserDto.email); expect(user.contactPermission).toBe(true); expect(user.serviceEmailsPermission).toBe(false); expect(repoSaveSpy).toHaveBeenCalledWith({ ...mockUserEntity, ...updateUserDto }); expect(repoSaveSpy).toHaveBeenCalled(); + expect(authServiceUpdateEmailSpy).toHaveBeenCalledWith( + mockUserEntity.firebaseUid, + updateUserDto.email, + ); }); it('should not fail update on crisp api call errors', async () => { const mocked = jest.mocked(updateCrispProfile); mocked.mockRejectedValue(new Error('Crisp API call failed')); - const user = await service.updateUser(updateUserDto, { user: mockUserEntity }); + const user = await service.updateUser(updateUserDto, mockUserEntity.id); await new Promise(process.nextTick); // wait for async funcs to resolve expect(mocked).toHaveBeenCalled(); - expect(user.name).toBe('new name'); - expect(user.email).toBe('user@email.com'); + expect(user.name).toBe(updateUserDto.name); + expect(user.email).toBe(updateUserDto.email); mocked.mockReset(); }); @@ -307,11 +313,11 @@ describe('UserService', () => { const mocked = jest.mocked(updateMailchimpProfile); mocked.mockRejectedValue(new Error('Mailchimp API call failed')); - const user = await service.updateUser(updateUserDto, { user: mockUserEntity }); + const user = await service.updateUser(updateUserDto, mockUserEntity.id); await new Promise(process.nextTick); // wait for async funcs to resolve expect(mocked).toHaveBeenCalled(); - expect(user.name).toBe('new name'); - expect(user.email).toBe('user@email.com'); + expect(user.name).toBe(updateUserDto.name); + expect(user.email).toBe(updateUserDto.email); mocked.mockReset(); }); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index f70cd1f6..a47a1b4b 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -10,6 +10,7 @@ import { SubscriptionUserService } from 'src/subscription-user/subscription-user import { TherapySessionService } from 'src/therapy-session/therapy-session.service'; import { SIGNUP_TYPE } from 'src/utils/constants'; import { FIREBASE_ERRORS } from 'src/utils/errors'; +import { FIREBASE_EVENTS } from 'src/utils/logs'; import { createServiceUserProfiles, updateServiceUserProfilesUser, @@ -191,12 +192,27 @@ export class UserService { return await this.deleteUser(user); } - public async updateUser(updateUserDto: Partial, { user: { id } }: GetUserDto) { - const user = await this.userRepository.findOneBy({ id }); + public async updateUser(updateUserDto: Partial, userId: string) { + const user = await this.userRepository.findOneBy({ id: userId }); if (!user) { throw new HttpException('USER NOT FOUND', HttpStatus.NOT_FOUND); } + + if (updateUserDto.email) { + // check whether email has been updated already in firebase + const firebaseUser = await this.authService.getFirebaseUser(user.email); + if (firebaseUser.email !== updateUserDto.email) { + await this.authService.updateFirebaseUserEmail(user.firebaseUid, updateUserDto.email); + this.logger.log({ event: FIREBASE_EVENTS.UPDATE_FIREBASE_USER_EMAIL, userId: user.id }); + } else { + this.logger.log({ + event: FIREBASE_EVENTS.UPDATE_FIREBASE_EMAIL_ALREADY_UPDATED, + userId: user.id, + }); + } + } + const newUserData: UserEntity = { ...user, ...updateUserDto, diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 9d793985..74b43e0a 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -7,4 +7,15 @@ export enum FIREBASE_ERRORS { CREATE_USER_WEAK_PASSWORD = 'CREATE_USER_WEAK_PASSWORD', CREATE_USER_ALREADY_EXISTS = 'CREATE_USER_ALREADY_EXISTS', ID_TOKEN_EXPIRED = 'ID_TOKEN_EXPIRED', + UPDATE_USER_INVALID_EMAIL = 'UPDATE_USER_INVALID_EMAIL', + UPDATE_USER_WEAK_PASSWORD = 'UPDATE_USER_WEAK_PASSWORD', + UPDATE_USER_ALREADY_EXISTS = 'UPDATE_USER_ALREADY_EXISTS', + UPDATE_USER_FIREBASE_ERROR = 'UPDATE_USER_FIREBASE_ERROR', +} + +export enum AUTH_GUARD_ERRORS { + TOKEN_EXPIRED = 'TOKEN_EXPIRED', + PARSING_ERROR = 'PARSING_ERROR', + MISSING_HEADER = 'MISSING_HEADER', + USER_NOT_FOUND = 'USER_NOT_FOUND', } diff --git a/src/utils/logs.ts b/src/utils/logs.ts new file mode 100644 index 00000000..1a3418dc --- /dev/null +++ b/src/utils/logs.ts @@ -0,0 +1,4 @@ +export enum FIREBASE_EVENTS { + UPDATE_FIREBASE_USER_EMAIL = 'UPDATE_FIREBASE_USER_EMAIL', + UPDATE_FIREBASE_EMAIL_ALREADY_UPDATED = 'UPDATE_FIREBASE_EMAIL_ALREADY_UPDATED', +}