import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import * as O from 'fp-ts/Option'; import * as E from 'fp-ts/Either'; import * as TO from 'fp-ts/TaskOption'; import * as TE from 'fp-ts/TaskEither'; import * as T from 'fp-ts/Task'; import * as A from 'fp-ts/Array'; import { pipe, constVoid } from 'fp-ts/function'; import { AuthUser } from 'src/types/AuthUser'; import { USER_NOT_FOUND } from 'src/errors'; import { SessionType, User } from './user.model'; import { USER_UPDATE_FAILED } from 'src/errors'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { stringToJson, taskEitherValidateArraySeq } from 'src/utils'; import { UserDataHandler } from './user.data.handler'; import { User as DbUser } from '@prisma/client'; @Injectable() export class UserService { constructor( private prisma: PrismaService, private readonly pubsub: PubSubService, ) {} private userDataHandlers: UserDataHandler[] = []; registerUserDataHandler(handler: UserDataHandler) { this.userDataHandlers.push(handler); } /** * Converts a prisma user object to a user object * * @param dbUser Prisma User object * @returns User object */ convertDbUserToUser(dbUser: DbUser): User { const dbCurrentRESTSession = dbUser.currentRESTSession; const dbCurrentGQLSession = dbUser.currentGQLSession; return { ...dbUser, currentRESTSession: dbCurrentRESTSession ? JSON.stringify(dbCurrentRESTSession) : null, currentGQLSession: dbCurrentGQLSession ? JSON.stringify(dbCurrentGQLSession) : null, }; } /** * Find User with given email id * * @param email User's email * @returns Option of found User */ async findUserByEmail(email: string): Promise> { try { const user = await this.prisma.user.findUniqueOrThrow({ where: { email: email, }, }); return O.some(user); } catch (error) { return O.none; } } /** * Find User with given ID * * @param userUid User ID * @returns Option of found User */ async findUserById(userUid: string): Promise> { try { const user = await this.prisma.user.findUniqueOrThrow({ where: { uid: userUid, }, }); return O.some(user); } catch (error) { return O.none; } } /** * Update User with new generated hashed refresh token * * @param refreshTokenHash Hash of newly generated refresh token * @param userUid User uid * @returns Either of User with updated refreshToken */ async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) { try { const user = await this.prisma.user.update({ where: { uid: userUid, }, data: { refreshToken: refreshTokenHash, }, }); return E.right(user); } catch (error) { return E.left(USER_NOT_FOUND); } } /** * Create a new User when logged in via a Magic Link * * @param email User's Email * @returns Created User */ async createUserViaMagicLink(email: string) { const createdUser = await this.prisma.user.create({ data: { email: email, providerAccounts: { create: { provider: 'magic', providerAccountId: email, }, }, }, }); return createdUser; } /** * Create a new User when logged in via a SSO provider * * @param accessTokenSSO User's access token generated by providers * @param refreshTokenSSO User's refresh token generated by providers * @param profile Data received from SSO provider on the users account * @returns Created User */ async createUserSSO( accessTokenSSO: string, refreshTokenSSO: string, profile, ) { const userDisplayName = !profile.displayName ? null : profile.displayName; const userPhotoURL = !profile.photos ? null : profile.photos[0].value; const createdUser = await this.prisma.user.create({ data: { displayName: userDisplayName, email: profile.emails[0].value, photoURL: userPhotoURL, providerAccounts: { create: { provider: profile.provider, providerAccountId: profile.id, providerRefreshToken: refreshTokenSSO, providerAccessToken: accessTokenSSO, }, }, }, }); return createdUser; } /** * Create a new Account for a given User * * @param user User object * @param accessToken User's access token generated by providers * @param refreshToken User's refresh token generated by providers * @param profile Data received from SSO provider on the users account * @returns Created Account */ async createProviderAccount( user: AuthUser, accessToken: string, refreshToken: string, profile, ) { const createdProvider = await this.prisma.account.create({ data: { provider: profile.provider, providerAccountId: profile.id, providerRefreshToken: refreshToken ? refreshToken : null, providerAccessToken: accessToken ? accessToken : null, user: { connect: { uid: user.uid, }, }, }, }); return createdProvider; } /** * Update User displayName and photoURL * * @param user User object * @param profile Data received from SSO provider on the users account * @returns Updated user object */ async updateUserDetails(user: AuthUser, profile) { try { const updatedUser = await this.prisma.user.update({ where: { uid: user.uid, }, data: { displayName: !profile.displayName ? null : profile.displayName, photoURL: !profile.photos ? null : profile.photos[0].value, }, }); return E.right(updatedUser); } catch (error) { return E.left(USER_NOT_FOUND); } } /** * Update a user's sessions * @param user User object * @param currentRESTSession user's current REST session * @param currentGQLSession user's current GQL session * @returns a Either of User or error */ async updateUserSessions( user: AuthUser, currentSession: string, sessionType: string, ): Promise | E.Left> { const validatedSession = await this.validateSession(currentSession); if (E.isLeft(validatedSession)) return E.left(validatedSession.left); try { const sessionObj = {}; switch (sessionType) { case SessionType.GQL: sessionObj['currentGQLSession'] = validatedSession.right; break; case SessionType.REST: sessionObj['currentRESTSession'] = validatedSession.right; break; default: return E.left(USER_UPDATE_FAILED); } const dbUpdatedUser = await this.prisma.user.update({ where: { uid: user.uid }, data: sessionObj, }); const updatedUser = this.convertDbUserToUser(dbUpdatedUser); // Publish subscription for user updates await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser); return E.right(updatedUser); } catch (e) { return E.left(USER_UPDATE_FAILED); } } /** * Validate and parse currentRESTSession and currentGQLSession * @param sessionData string of the session * @returns a Either of JSON object or error */ async validateSession(sessionData: string) { const jsonSession = stringToJson(sessionData); if (E.isLeft(jsonSession)) return E.left(jsonSession.left); return E.right(jsonSession.right); } /** * Fetch all the users in the `User` table based on cursor * @param cursorID string of userUID or null * @param take number of users to query * @returns an array of `User` object */ async fetchAllUsers(cursorID: string, take: number) { const fetchedUsers = await this.prisma.user.findMany({ skip: cursorID ? 1 : 0, take: take, cursor: cursorID ? { uid: cursorID } : undefined, }); return fetchedUsers; } /** * Fetch the number of users in db * @returns a count (Int) of user records in DB */ async getUsersCount() { const usersCount = await this.prisma.user.count(); return usersCount; } /** * Change a user to an admin by toggling isAdmin param to true * @param userUID user UID * @returns a Either of `User` object or error */ async makeAdmin(userUID: string) { try { const elevatedUser = await this.prisma.user.update({ where: { uid: userUID, }, data: { isAdmin: true, }, }); return E.right(elevatedUser); } catch (error) { return E.left(USER_NOT_FOUND); } } /** * Fetch all the admin users * @returns an array of admin users */ async fetchAdminUsers() { const admins = this.prisma.user.findMany({ where: { isAdmin: true, }, }); return admins; } /** * Deletes a user account by UID * @param uid User UID * @returns an Either of string or boolean */ async deleteUserAccount(uid: string) { try { await this.prisma.user.delete({ where: { uid: uid, }, }); return E.right(true); } catch (e) { return E.left(USER_NOT_FOUND); } } /** * Get user deletion error messages when the data handlers are initialised in respective modules * @param user User Object * @returns an TaskOption of string array */ getUserDeletionErrors(user: AuthUser): TO.TaskOption { return pipe( this.userDataHandlers, A.map((handler) => pipe( handler.canAllowUserDeletion(user), TO.matchE( () => TE.right(undefined), (error) => TE.left(error), ), ), ), taskEitherValidateArraySeq, TE.matchE( (e) => TO.some(e), () => TO.none, ), ); } /** * Deletes a user by UID * @param user User Object * @returns an TaskEither of string or boolean */ deleteUserByUID(user: AuthUser) { return pipe( this.getUserDeletionErrors(user), TO.matchEW( () => pipe( this.userDataHandlers, A.map((handler) => handler.onUserDelete(user)), T.sequenceArray, T.map(constVoid), TE.fromTask, ) as TE.TaskEither, (errors): TE.TaskEither => TE.left(errors), ), TE.chainW(() => () => this.deleteUserAccount(user.uid)), TE.chainFirst(() => TE.fromTask(() => this.pubsub.publish(`user/${user.uid}/deleted`, { uid: user.uid, displayName: user.displayName, email: user.email, photoURL: user.photoURL, isAdmin: user.isAdmin, createdOn: user.createdOn, currentGQLSession: user.currentGQLSession, currentRESTSession: user.currentRESTSession, }), ), ), TE.mapLeft((errors) => errors.toString()), ); } /** * Change the user from an admin by toggling isAdmin param to false * @param userUID user UID * @returns a Either of `User` object or error */ async removeUserAsAdmin(userUID: string) { try { const user = await this.prisma.user.update({ where: { uid: userUID, }, data: { isAdmin: false, }, }); return E.right(user); } catch (error) { return E.left(USER_NOT_FOUND); } } }