refactor: team invitation module

This commit is contained in:
Mir Arif Hasan
2023-07-07 12:08:39 +06:00
committed by Andrew Bastin
parent 6bc748a267
commit 54bef30cf8
9 changed files with 235 additions and 416 deletions

View File

@@ -181,7 +181,7 @@ export class AdminService {
* @returns an array team invitations * @returns an array team invitations
*/ */
async pendingInvitationCountInTeam(teamID: string) { async pendingInvitationCountInTeam(teamID: string) {
const invitations = await this.teamInvitationService.getAllTeamInvitations( const invitations = await this.teamInvitationService.getTeamInvitations(
teamID, teamID,
); );
@@ -257,7 +257,7 @@ export class AdminService {
if (E.isRight(userInvitation)) { if (E.isRight(userInvitation)) {
await this.teamInvitationService.revokeInvitation( await this.teamInvitationService.revokeInvitation(
userInvitation.right.id, userInvitation.right.id,
)(); );
} }
return E.right(addedUser.right); return E.right(addedUser.right);

View File

@@ -228,7 +228,7 @@ export class AuthService {
url = process.env.VITE_BASE_URL; url = process.env.VITE_BASE_URL;
} }
await this.mailerService.sendAuthEmail(email, { await this.mailerService.sendEmail(email, {
template: 'code-your-own', template: 'code-your-own',
variables: { variables: {
inviteeEmail: email, inviteeEmail: email,

View File

@@ -5,7 +5,6 @@ import {
UserMagicLinkMailDescription, UserMagicLinkMailDescription,
} from './MailDescriptions'; } from './MailDescriptions';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import * as TE from 'fp-ts/TaskEither';
import { EMAIL_FAILED } from 'src/errors'; import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer'; import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
@@ -35,33 +34,14 @@ export class MailerService {
/** /**
* Sends an email to the given email address given a mail description * Sends an email to the given email address given a mail description
* @param to The email address to be sent to (NOTE: this is not validated) * @param to Receiver's email id
* @param mailDesc Definition of what email to be sent * @param mailDesc Definition of what email to be sent
* @returns Response if email was send successfully or not
*/ */
sendMail( async sendEmail(
to: string, to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription, mailDesc: MailDescription | UserMagicLinkMailDescription,
) { ) {
return TE.tryCatch(
async () => {
await this.nestMailerService.sendMail({
to,
template: mailDesc.template,
subject: this.resolveSubjectForMailDesc(mailDesc),
context: mailDesc.variables,
});
},
() => EMAIL_FAILED,
);
}
/**
*
* @param to Receiver's email id
* @param mailDesc Details of email to be sent for Magic-Link auth
* @returns Response if email was send successfully or not
*/
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
try { try {
await this.nestMailerService.sendMail({ await this.nestMailerService.sendMail({
to, to,

View File

@@ -12,12 +12,10 @@ import { TeamInvitation } from './team-invitation.model';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe } from 'fp-ts/function'; import { pipe } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither'; import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model'; import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
import { EmailCodec } from 'src/types/Email';
import { import {
INVALID_EMAIL,
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
USER_NOT_FOUND, USER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
@@ -79,7 +77,7 @@ export class TeamInvitationResolver {
'Gets the Team Invitation with the given ID, or null if not exists', 'Gets the Team Invitation with the given ID, or null if not exists',
}) })
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard) @UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
teamInvitation( async teamInvitation(
@GqlUser() user: User, @GqlUser() user: User,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
@@ -88,17 +86,11 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamInvitation> { ): Promise<TeamInvitation> {
return pipe( const teamInvitation = await this.teamInvitationService.getInvitation(
this.teamInvitationService.getInvitation(inviteID), inviteID,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), );
TE.chainW( if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
TE.fromPredicate( return teamInvitation.value;
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
TE.getOrElse(throwErr),
)();
} }
@Mutation(() => TeamInvitation, { @Mutation(() => TeamInvitation, {
@@ -106,7 +98,7 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
createTeamInvitation( async createTeamInvitation(
@GqlUser() @GqlUser()
user: User, user: User,
@@ -128,34 +120,15 @@ export class TeamInvitationResolver {
}) })
inviteeRole: TeamMemberRole, inviteeRole: TeamMemberRole,
): Promise<TeamInvitation> { ): Promise<TeamInvitation> {
return pipe( const teamInvitation = await this.teamInvitationService.createInvitation(
TE.Do, user,
teamID,
inviteeEmail,
inviteeRole,
);
// Validate email if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left);
TE.bindW('email', () => return teamInvitation.right;
pipe(
EmailCodec.decode(inviteeEmail),
TE.fromEither,
TE.mapLeft(() => INVALID_EMAIL),
),
),
// Validate and get Team
TE.bindW('team', () => this.teamService.getTeamWithIDTE(teamID)),
// Create team
TE.chainW(({ email, team }) =>
this.teamInvitationService.createInvitation(
user,
team,
email,
inviteeRole,
),
),
// If failed, throw err (so the message is passed) else return value
TE.getOrElse(throwErr),
)();
} }
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
@@ -163,7 +136,7 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard) @UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
revokeTeamInvitation( async revokeTeamInvitation(
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -171,18 +144,18 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<true> { ): Promise<true> {
return pipe( const isRevoked = await this.teamInvitationService.revokeInvitation(
this.teamInvitationService.revokeInvitation(inviteID), inviteID,
TE.map(() => true as const), );
TE.getOrElse(throwErr), if (E.isLeft(isRevoked)) throwErr(isRevoked.left);
)(); return true;
} }
@Mutation(() => TeamMember, { @Mutation(() => TeamMember, {
description: 'Accept an Invitation', description: 'Accept an Invitation',
}) })
@UseGuards(GqlAuthGuard, TeamInviteeGuard) @UseGuards(GqlAuthGuard, TeamInviteeGuard)
acceptTeamInvitation( async acceptTeamInvitation(
@GqlUser() user: User, @GqlUser() user: User,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
@@ -191,10 +164,12 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamMember> { ): Promise<TeamMember> {
return pipe( const teamMember = await this.teamInvitationService.acceptInvitation(
this.teamInvitationService.acceptInvitation(inviteID, user), inviteID,
TE.getOrElse(throwErr), user,
)(); );
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
} }
// Subscriptions // Subscriptions

View File

@@ -1,21 +1,21 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as TO from 'fp-ts/TaskOption';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { pipe, flow, constVoid } from 'fp-ts/function'; import * as TE from 'fp-ts/TaskEither';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { Team, TeamMemberRole } from 'src/team/team.model'; import { TeamInvitation as DBTeamInvitation } from '@prisma/client';
import { Email } from 'src/types/Email'; import { TeamMember, TeamMemberRole } from 'src/team/team.model';
import { User } from 'src/user/user.model'; import { User } from 'src/user/user.model';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { import {
INVALID_EMAIL, INVALID_EMAIL,
TEAM_INVALID_ID,
TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_EMAIL_DO_NOT_MATCH, TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_MEMBER_HAS_INVITE, TEAM_INVITE_MEMBER_HAS_INVITE,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
USER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamInvitation } from './team-invitation.model'; import { TeamInvitation } from './team-invitation.model';
import { MailerService } from 'src/mailer/mailer.service'; import { MailerService } from 'src/mailer/mailer.service';
@@ -36,34 +36,27 @@ export class TeamInvitationService {
this.getInvitation = this.getInvitation.bind(this); this.getInvitation = this.getInvitation.bind(this);
} }
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> { castToTeamInvitation(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
return pipe( return {
() => ...dbTeamInvitation,
this.prisma.teamInvitation.findUnique({ inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
where: { };
id: inviteID,
},
}),
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)),
TO.map((x) => x as TeamInvitation),
);
} }
getInvitationWithEmail(email: Email, team: Team) { async getInvitation(
return pipe( inviteID: string,
() => ): Promise<O.None | O.Some<TeamInvitation>> {
this.prisma.teamInvitation.findUnique({ try {
where: { const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
teamID_inviteeEmail: { where: {
inviteeEmail: email, id: inviteID,
teamID: team.id, },
}, });
},
}), return O.some(this.castToTeamInvitation(dbInvitation));
TO.fromTask, } catch (e) {
TO.chain(flow(O.fromNullable, TO.fromOption)), return O.none;
); }
} }
/** /**
@@ -92,213 +85,148 @@ export class TeamInvitationService {
} }
} }
createInvitation( async createInvitation(
creator: User, creator: User,
team: Team, teamID: string,
inviteeEmail: Email, inviteeEmail: string,
inviteeRole: TeamMemberRole, inviteeRole: TeamMemberRole,
) { ): Promise<E.Left<string> | E.Right<TeamInvitation>> {
return pipe( // validate email
// Perform all validation checks const isEmailValid = validateEmail(inviteeEmail);
TE.sequenceArray([ if (!isEmailValid) return E.left(INVALID_EMAIL);
// creator should be a TeamMember
pipe(
this.teamService.getTeamMemberTE(team.id, creator.uid),
TE.map(constVoid),
),
// Invitee should not be a team member // team ID should valid
pipe( const team = await this.teamService.getTeamWithID(teamID);
async () => await this.userService.findUserByEmail(inviteeEmail), if (!team) return E.left(TEAM_INVALID_ID);
TO.foldW(
() => TE.right(undefined), // If no user, short circuit to completion
(user) =>
pipe(
// If user is found, check if team member
this.teamService.getTeamMemberTE(team.id, user.uid),
TE.foldW(
() => TE.right(undefined), // Not team-member, this is good
() => TE.left(TEAM_INVITE_ALREADY_MEMBER), // Is team member, not good
),
),
),
TE.map(constVoid),
),
// Should not have an existing invite // invitation creator should be a TeamMember
pipe( const teamMemberCreator = await this.teamService.getTeamMember(
this.getInvitationWithEmail(inviteeEmail, team), team.id,
TE.fromTaskOption(() => null), creator.uid,
TE.swap,
TE.map(constVoid),
TE.mapLeft(() => TEAM_INVITE_MEMBER_HAS_INVITE),
),
]),
// Create the invitation
TE.chainTaskK(
() => () =>
this.prisma.teamInvitation.create({
data: {
teamID: team.id,
inviteeEmail,
inviteeRole,
creatorUid: creator.uid,
},
}),
),
// Send email, this is a side effect
TE.chainFirstTaskK((invitation) =>
pipe(
this.mailerService.sendMail(inviteeEmail, {
template: 'team-invitation',
variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${invitation.id}`,
invite_team_name: team.name,
},
}),
TE.getOrElseW(() => T.of(undefined)), // This value doesn't matter as we don't mind the return value (chainFirst) as long as the task completes
),
),
// Send PubSub topic
TE.chainFirstTaskK((invitation) =>
TE.fromTask(async () => {
const inv: TeamInvitation = {
id: invitation.id,
teamID: invitation.teamID,
creatorUid: invitation.creatorUid,
inviteeEmail: invitation.inviteeEmail,
inviteeRole: TeamMemberRole[invitation.inviteeRole],
};
this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv);
}),
),
// Map to model type
TE.map((x) => x as TeamInvitation),
); );
if (!teamMemberCreator) return E.left(TEAM_MEMBER_NOT_FOUND);
// invitee email should be a infra user
const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
if (O.isSome(inviteeUser)) {
// invitee should not already a member
const teamMemberInvitee = await this.teamService.getTeamMember(
team.id,
inviteeUser.value.uid,
);
if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// check invitee already invited earlier or not
const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
inviteeEmail,
team.id,
);
if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE);
// create the invitation
const dbInvitation = await this.prisma.teamInvitation.create({
data: {
teamID: team.id,
inviteeEmail,
inviteeRole,
creatorUid: creator.uid,
},
});
await this.mailerService.sendEmail(inviteeEmail, {
template: 'team-invitation',
variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name,
},
});
const invitation = this.castToTeamInvitation(dbInvitation);
this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
return E.right(invitation);
} }
revokeInvitation(inviteID: string) { async revokeInvitation(
return pipe( inviteID: string,
// Make sure invite exists ): Promise<E.Left<string> | E.Right<boolean>> {
this.getInvitation(inviteID), // check if the invite exists
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// Delete team invitation // delete the invite
TE.chainTaskK( await this.prisma.teamInvitation.delete({
() => () => where: {
this.prisma.teamInvitation.delete({ id: inviteID,
where: { },
id: inviteID, });
},
}),
),
// Emit Pubsub Event this.pubsub.publish(
TE.chainFirst((invitation) => `team/${invitation.value.teamID}/invite_removed`,
TE.fromTask(() => invitation.value.id,
this.pubsub.publish(
`team/${invitation.teamID}/invite_removed`,
invitation.id,
),
),
),
// We are not returning anything
TE.map(constVoid),
); );
return E.right(true);
} }
getAllInvitationsInTeam(team: Team) { async acceptInvitation(
return pipe( inviteID: string,
() => acceptedBy: User,
this.prisma.teamInvitation.findMany({ ): Promise<E.Left<string> | E.Right<TeamMember>> {
where: { // check if the invite exists
teamID: team.id, const invitation = await this.getInvitation(inviteID);
}, if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
}),
T.map((x) => x as TeamInvitation[]), // make sure the user is not already a member of the team
const teamMemberInvitee = await this.teamService.getTeamMember(
invitation.value.teamID,
acceptedBy.uid,
); );
} if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER);
acceptInvitation(inviteID: string, acceptedBy: User) { // make sure the user is the same as the invitee
return pipe( if (
TE.Do, acceptedBy.email.toLowerCase() !==
invitation.value.inviteeEmail.toLowerCase()
)
return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
// First get the invitation // add the user to the team
TE.bindW('invitation', () => let teamMember: TeamMember;
pipe( try {
this.getInvitation(inviteID), teamMember = await this.teamService.addMemberToTeam(
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), invitation.value.teamID,
), acceptedBy.uid,
), invitation.value.inviteeRole,
);
} catch (e) {
return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// Validation checks // delete the invite
TE.chainFirstW(({ invitation }) => await this.revokeInvitation(inviteID);
TE.sequenceArray([
// Make sure the invited user is not part of the team
pipe(
this.teamService.getTeamMemberTE(invitation.teamID, acceptedBy.uid),
TE.swap,
TE.bimap(
() => TEAM_INVITE_ALREADY_MEMBER,
constVoid, // The return type is ignored
),
),
// Make sure the invited user and accepting user has the same email return E.right(teamMember);
pipe(
undefined,
TE.fromPredicate(
() =>
acceptedBy.email.toLowerCase() ===
invitation.inviteeEmail.toLowerCase(),
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
]),
),
// Add the team member
// TODO: Somehow bring subscriptions to this ?
TE.bindW('teamMember', ({ invitation }) =>
pipe(
TE.tryCatch(
() =>
this.teamService.addMemberToTeam(
invitation.teamID,
acceptedBy.uid,
invitation.inviteeRole,
),
() => TEAM_INVITE_ALREADY_MEMBER, // Can only fail if Team Member already exists, which we checked, but due to async lets assert that here too
),
),
),
TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)),
TE.map(({ teamMember }) => teamMember),
);
} }
/** /**
* Fetch the count invitations for a given team. * Fetch all team invitations for a given team.
* @param teamID team id * @param teamID team id
* @returns a count team invitations for a team * @returns array of team invitations for a team
*/ */
async getAllTeamInvitations(teamID: string) { async getTeamInvitations(teamID: string) {
const invitations = await this.prisma.teamInvitation.findMany({ const dbInvitations = await this.prisma.teamInvitation.findMany({
where: { where: {
teamID: teamID, teamID: teamID,
}, },
}); });
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
this.castToTeamInvitation(dbInvitation),
);
return invitations; return invitations;
} }
} }

View File

@@ -1,18 +1,15 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE, TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamMemberRole } from 'src/team/team.model'; import { TeamMemberRole } from 'src/team/team.model';
@@ -24,48 +21,30 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), // Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get the invite // Get the invite
TE.bindW('invite', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW((inviteID) =>
pipe(
this.teamInviteService.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
TE.bindW('user', ({ gqlCtx }) => const invitation = await this.teamInviteService.getInvitation(inviteID);
pipe( if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
gqlCtx.getContext().req.user,
O.fromNullable,
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
TE.bindW('userMember', ({ invite, user }) => // Fetch team member details of this user
this.teamService.getTeamMemberTE(invite.teamID, user.uid), const teamMember = await this.teamService.getTeamMember(
), invitation.value.teamID,
user.uid,
);
TE.chainW( if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
TE.fromPredicate( if (teamMember.role !== TeamMemberRole.OWNER)
({ userMember }) => userMember.role === TeamMemberRole.OWNER, throwErr(TEAM_NOT_REQUIRED_ROLE);
() => TEAM_NOT_REQUIRED_ROLE,
),
),
TE.fold( return true;
(err) => throwErr(err),
() => T.of(true),
),
)();
} }
} }

View File

@@ -1,17 +1,13 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NOT_VALID_VIEWER,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
@@ -23,50 +19,33 @@ export class TeamInviteViewerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
// Get GQL Context // Get user
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get user // Get the invite
TE.bindW('user', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getContext().req.user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get the invite const invitation = await this.teamInviteService.getInvitation(inviteID);
TE.bindW('invite', ({ gqlCtx }) => if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Check if the user and the invite email match, else if we can resolver the user as a team member // Check if the user and the invite email match, else if we can resolver the user as a team member
// any better solution ? // any better solution ?
TE.chainW(({ user, invite }) => if (
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase() user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
? TE.of(true) ) {
: pipe( const teamMember = await this.teamService.getTeamMember(
this.teamService.getTeamMemberTE(invite.teamID, user.uid), invitation.value.teamID,
TE.map(() => true), user.uid,
), );
),
TE.mapLeft((e) => if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e, }
),
TE.fold(throwErr, () => T.of(true)), return true;
)();
} }
} }

View File

@@ -1,9 +1,6 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
@@ -23,45 +20,26 @@ export class TeamInviteeGuard implements CanActivate {
constructor(private readonly teamInviteService: TeamInvitationService) {} constructor(private readonly teamInviteService: TeamInvitationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL Context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
// Get execution context // Get user
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get user // Get the invite
TE.bindW('user', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getContext().req.user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get invite const invitation = await this.teamInviteService.getInvitation(inviteID);
TE.bindW('invite', ({ gqlCtx }) => if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Check if the emails match if (
TE.chainW( user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
TE.fromPredicate( ) {
({ user, invite }) => throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
user.email.toLowerCase() === invite.inviteeEmail.toLowerCase(), }
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
// Fold it to a promise return true;
TE.fold(throwErr, () => T.of(true)),
)();
} }
} }

View File

@@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver {
complexity: 10, complexity: 10,
}) })
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> { teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
return this.teamInviteService.getAllInvitationsInTeam(team)(); return this.teamInviteService.getTeamInvitations(team.id);
} }
} }