Files
hoppscotch/packages/hoppscotch-backend/src/user/user.service.ts
Akash K c49573db65 fix: serialize user sessions properly (#62)
* fix: cast prisma user object to user object

* chore: improved consistency

* chore: cast function renamed

---------

Co-authored-by: Mir Arif Hasan <arif.ishan05@gmail.com>
2023-04-03 09:47:40 +05:30

448 lines
12 KiB
TypeScript

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<O.None | O.Some<AuthUser>> {
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<O.None | O.Some<AuthUser>> {
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.Right<User> | E.Left<string>> {
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<string[]> {
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<never, void>,
(errors): TE.TaskEither<string[], void> => TE.left(errors),
),
TE.chainW(() => () => this.deleteUserAccount(user.uid)),
TE.chainFirst(() =>
TE.fromTask(() =>
this.pubsub.publish(`user/${user.uid}/deleted`, <User>{
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);
}
}
}