feat: team invitation module added
This commit is contained in:
@@ -12,6 +12,7 @@ import { TeamModule } from './team/team.module';
|
||||
import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
|
||||
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
||||
import { TeamRequestModule } from './team-request/team-request.module';
|
||||
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -53,6 +54,7 @@ import { TeamRequestModule } from './team-request/team-request.module';
|
||||
TeamEnvironmentsModule,
|
||||
TeamCollectionModule,
|
||||
TeamRequestModule,
|
||||
TeamInvitationModule,
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TeamMember } from 'src/team/team.model';
|
||||
import { TeamEnvironment } from 'src/team-environments/team-environments.model';
|
||||
import { TeamCollection } from 'src/team-collection/team-collection.model';
|
||||
import { TeamRequest } from 'src/team-request/team-request.model';
|
||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
||||
|
||||
// A custom message type that defines the topic and the corresponding payload.
|
||||
// For every module that publishes a subscription add its type def and the possible subscription type.
|
||||
@@ -33,4 +34,6 @@ export type TopicDef = {
|
||||
[topic: `user_history/${string}/deleted_many`]: number;
|
||||
[topic: `team_req/${string}/${'req_created' | 'req_updated'}`]: TeamRequest;
|
||||
[topic: `team_req/${string}/req_deleted`]: string;
|
||||
[topic: `team/${string}/invite_added`]: TeamInvitation;
|
||||
[topic: `team/${string}/invite_removed`]: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
import { TeamMemberRole } from '../team/team.model';
|
||||
|
||||
@ObjectType()
|
||||
export class TeamInvitation {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the invite',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the team the invite is to',
|
||||
})
|
||||
teamID: string;
|
||||
|
||||
@Field(() => ID, {
|
||||
description: 'UID of the creator of the invite',
|
||||
})
|
||||
creatorUid: string;
|
||||
|
||||
@Field(() => ID, {
|
||||
description: 'Email of the invitee',
|
||||
})
|
||||
inviteeEmail: string;
|
||||
|
||||
@Field(() => TeamMemberRole, {
|
||||
description: 'The role that will be given to the invitee',
|
||||
})
|
||||
inviteeRole: TeamMemberRole;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailerModule } from 'src/mailer/mailer.module';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
||||
import { TeamModule } from 'src/team/team.module';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
import { TeamInvitationResolver } from './team-invitation.resolver';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard';
|
||||
import { TeamInviteViewerGuard } from './team-invite-viewer.guard';
|
||||
import { TeamInviteeGuard } from './team-invitee.guard';
|
||||
import { TeamTeamInviteExtResolver } from './team-teaminvite-ext.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TeamModule, PubSubModule, UserModule, MailerModule],
|
||||
providers: [
|
||||
TeamInvitationService,
|
||||
TeamInvitationResolver,
|
||||
TeamTeamInviteExtResolver,
|
||||
TeamInviteeGuard,
|
||||
TeamInviteViewerGuard,
|
||||
TeamInviteTeamOwnerGuard,
|
||||
],
|
||||
exports: [TeamInvitationService],
|
||||
})
|
||||
export class TeamInvitationModule {}
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
Subscription,
|
||||
} from '@nestjs/graphql';
|
||||
import { TeamInvitation } from './team-invitation.model';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
|
||||
import { EmailCodec } from 'src/types/Email';
|
||||
import {
|
||||
INVALID_EMAIL,
|
||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { TeamInviteeGuard } from './team-invitee.guard';
|
||||
import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
|
||||
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
|
||||
import { TeamInviteViewerGuard } from './team-invite-viewer.guard';
|
||||
import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
|
||||
@Resolver(() => TeamInvitation)
|
||||
export class TeamInvitationResolver {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly teamService: TeamService,
|
||||
private readonly teamInvitationService: TeamInvitationService,
|
||||
|
||||
private readonly pubsub: PubSubService,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => Team, {
|
||||
complexity: 5,
|
||||
description: 'Get the team associated to the invite',
|
||||
})
|
||||
async team(@Parent() teamInvitation: TeamInvitation): Promise<Team> {
|
||||
return pipe(
|
||||
this.teamService.getTeamWithIDTE(teamInvitation.teamID),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@ResolveField(() => User, {
|
||||
complexity: 5,
|
||||
description: 'Get the creator of the invite',
|
||||
})
|
||||
async creator(@Parent() teamInvitation: TeamInvitation): Promise<User> {
|
||||
let user = await this.userService.findUserById(teamInvitation.creatorUid);
|
||||
if (O.isNone(user)) throwErr(USER_NOT_FOUND);
|
||||
|
||||
return {
|
||||
...user.value,
|
||||
currentGQLSession: JSON.stringify(user.value.currentGQLSession),
|
||||
currentRESTSession: JSON.stringify(user.value.currentRESTSession),
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => TeamInvitation, {
|
||||
description:
|
||||
'Gets the Team Invitation with the given ID, or null if not exists',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
|
||||
teamInvitation(
|
||||
@GqlUser() user: User,
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
description: 'ID of the Team Invitation to lookup',
|
||||
type: () => ID,
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<TeamInvitation> {
|
||||
return pipe(
|
||||
this.teamInvitationService.getInvitation(inviteID),
|
||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
||||
TE.chainW(
|
||||
TE.fromPredicate(
|
||||
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
|
||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
),
|
||||
),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => TeamInvitation, {
|
||||
description: 'Creates a Team Invitation',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
||||
createTeamInvitation(
|
||||
@GqlUser()
|
||||
user: User,
|
||||
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
description: 'ID of the Team ID to invite from',
|
||||
type: () => ID,
|
||||
})
|
||||
teamID: string,
|
||||
@Args({
|
||||
name: 'inviteeEmail',
|
||||
description: 'Email of the user to invite',
|
||||
})
|
||||
inviteeEmail: string,
|
||||
@Args({
|
||||
name: 'inviteeRole',
|
||||
type: () => TeamMemberRole,
|
||||
description: 'Role to be given to the user',
|
||||
})
|
||||
inviteeRole: TeamMemberRole,
|
||||
): Promise<TeamInvitation> {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// Validate email
|
||||
TE.bindW('email', () =>
|
||||
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, {
|
||||
description: 'Revokes an invitation and deletes it',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
||||
revokeTeamInvitation(
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
type: () => ID,
|
||||
description: 'ID of the invite to revoke',
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<true> {
|
||||
return pipe(
|
||||
this.teamInvitationService.revokeInvitation(inviteID),
|
||||
TE.map(() => true as const),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => TeamMember, {
|
||||
description: 'Accept an Invitation',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, TeamInviteeGuard)
|
||||
acceptTeamInvitation(
|
||||
@GqlUser() user: User,
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
type: () => ID,
|
||||
description: 'ID of the Invite to accept',
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<TeamMember> {
|
||||
return pipe(
|
||||
this.teamInvitationService.acceptInvitation(inviteID, user),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
@Subscription(() => TeamInvitation, {
|
||||
description: 'Listens to when a Team Invitation is added',
|
||||
resolve: (value) => value,
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||
@RequiresTeamRole(
|
||||
TeamMemberRole.OWNER,
|
||||
TeamMemberRole.EDITOR,
|
||||
TeamMemberRole.VIEWER,
|
||||
)
|
||||
teamInvitationAdded(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'ID of the Team to listen to',
|
||||
})
|
||||
teamID: string,
|
||||
): AsyncIterator<TeamInvitation> {
|
||||
return this.pubsub.asyncIterator(`team/${teamID}/invite_added`);
|
||||
}
|
||||
|
||||
@Subscription(() => ID, {
|
||||
description: 'Listens to when a Team Invitation is removed',
|
||||
resolve: (value) => value,
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||
@RequiresTeamRole(
|
||||
TeamMemberRole.OWNER,
|
||||
TeamMemberRole.EDITOR,
|
||||
TeamMemberRole.VIEWER,
|
||||
)
|
||||
teamInvitationRemoved(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'ID of the Team to listen to',
|
||||
})
|
||||
teamID: string,
|
||||
): AsyncIterator<string> {
|
||||
return this.pubsub.asyncIterator(`team/${teamID}/invite_removed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as T from 'fp-ts/Task';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as TO from 'fp-ts/TaskOption';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import { pipe, flow, constVoid } from 'fp-ts/function';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Team, TeamMemberRole } from 'src/team/team.model';
|
||||
import { Email } from 'src/types/Email';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import {
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
TEAM_INVITE_MEMBER_HAS_INVITE,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
} from 'src/errors';
|
||||
import { TeamInvitation } from './team-invitation.model';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
|
||||
@Injectable()
|
||||
export class TeamInvitationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly userService: UserService,
|
||||
private readonly teamService: TeamService,
|
||||
private readonly mailerService: MailerService,
|
||||
|
||||
private readonly pubsub: PubSubService,
|
||||
) {
|
||||
this.getInvitation = this.getInvitation.bind(this);
|
||||
}
|
||||
|
||||
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
|
||||
return pipe(
|
||||
() =>
|
||||
this.prisma.teamInvitation.findUnique({
|
||||
where: {
|
||||
id: inviteID,
|
||||
},
|
||||
}),
|
||||
TO.fromTask,
|
||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
||||
TO.map((x) => x as TeamInvitation),
|
||||
);
|
||||
}
|
||||
|
||||
getInvitationWithEmail(email: Email, team: Team) {
|
||||
return pipe(
|
||||
() =>
|
||||
this.prisma.teamInvitation.findUnique({
|
||||
where: {
|
||||
teamID_inviteeEmail: {
|
||||
inviteeEmail: email,
|
||||
teamID: team.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
TO.fromTask,
|
||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
||||
);
|
||||
}
|
||||
|
||||
createInvitation(
|
||||
creator: User,
|
||||
team: Team,
|
||||
inviteeEmail: Email,
|
||||
inviteeRole: TeamMemberRole,
|
||||
) {
|
||||
return pipe(
|
||||
// Perform all validation checks
|
||||
TE.sequenceArray([
|
||||
// creator should be a TeamMember
|
||||
pipe(
|
||||
this.teamService.getTeamMemberTE(team.id, creator.uid),
|
||||
TE.map(constVoid),
|
||||
),
|
||||
|
||||
// Invitee should not be a team member
|
||||
pipe(
|
||||
async () => await this.userService.findUserByEmail(inviteeEmail),
|
||||
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
|
||||
pipe(
|
||||
this.getInvitationWithEmail(inviteeEmail, team),
|
||||
TE.fromTaskOption(() => null),
|
||||
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: `https://hoppscotch.io/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),
|
||||
);
|
||||
}
|
||||
|
||||
revokeInvitation(inviteID: string) {
|
||||
return pipe(
|
||||
// Make sure invite exists
|
||||
this.getInvitation(inviteID),
|
||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
||||
|
||||
// Delete team invitation
|
||||
TE.chainTaskK(
|
||||
() => () =>
|
||||
this.prisma.teamInvitation.delete({
|
||||
where: {
|
||||
id: inviteID,
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// Emit Pubsub Event
|
||||
TE.chainFirst((invitation) =>
|
||||
TE.fromTask(() =>
|
||||
this.pubsub.publish(
|
||||
`team/${invitation.teamID}/invite_removed`,
|
||||
invitation.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// We are not returning anything
|
||||
TE.map(constVoid),
|
||||
);
|
||||
}
|
||||
|
||||
getAllInvitationsInTeam(team: Team) {
|
||||
return pipe(
|
||||
() =>
|
||||
this.prisma.teamInvitation.findMany({
|
||||
where: {
|
||||
teamID: team.id,
|
||||
},
|
||||
}),
|
||||
T.map((x) => x as TeamInvitation[]),
|
||||
);
|
||||
}
|
||||
|
||||
acceptInvitation(inviteID: string, acceptedBy: User) {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// First get the invitation
|
||||
TE.bindW('invitation', () =>
|
||||
pipe(
|
||||
this.getInvitation(inviteID),
|
||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
||||
),
|
||||
),
|
||||
|
||||
// Validation checks
|
||||
TE.chainFirstW(({ invitation }) =>
|
||||
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
|
||||
pipe(
|
||||
undefined,
|
||||
TE.fromPredicate(
|
||||
(a) => acceptedBy.email === invitation.inviteeEmail,
|
||||
() => 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
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 {
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
TEAM_NOT_REQUIRED_ROLE,
|
||||
} from 'src/errors';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { TeamMemberRole } from 'src/team/team.model';
|
||||
|
||||
@Injectable()
|
||||
export class TeamInviteTeamOwnerGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly teamService: TeamService,
|
||||
private readonly teamInviteService: TeamInvitationService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
||||
|
||||
// Get the invite
|
||||
TE.bindW('invite', ({ gqlCtx }) =>
|
||||
pipe(
|
||||
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 }) =>
|
||||
pipe(
|
||||
gqlCtx.getContext().req.user,
|
||||
O.fromNullable,
|
||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
||||
),
|
||||
),
|
||||
|
||||
TE.bindW('userMember', ({ invite, user }) =>
|
||||
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
|
||||
),
|
||||
|
||||
TE.chainW(
|
||||
TE.fromPredicate(
|
||||
({ userMember }) => userMember.role === TeamMemberRole.OWNER,
|
||||
() => TEAM_NOT_REQUIRED_ROLE,
|
||||
),
|
||||
),
|
||||
|
||||
TE.fold(
|
||||
(err) => throwErr(err),
|
||||
() => T.of(true),
|
||||
),
|
||||
)();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
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 { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import {
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||
TEAM_INVITE_NOT_VALID_VIEWER,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
} from 'src/errors';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
|
||||
@Injectable()
|
||||
export class TeamInviteViewerGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly teamInviteService: TeamInvitationService,
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// Get GQL Context
|
||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
||||
|
||||
// Get user
|
||||
TE.bindW('user', ({ gqlCtx }) =>
|
||||
pipe(
|
||||
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
|
||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
||||
),
|
||||
),
|
||||
|
||||
// Get the invite
|
||||
TE.bindW('invite', ({ gqlCtx }) =>
|
||||
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
|
||||
// any better solution ?
|
||||
TE.chainW(({ user, invite }) =>
|
||||
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase()
|
||||
? TE.of(true)
|
||||
: pipe(
|
||||
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
|
||||
TE.map(() => true),
|
||||
),
|
||||
),
|
||||
|
||||
TE.mapLeft((e) =>
|
||||
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e,
|
||||
),
|
||||
|
||||
TE.fold(throwErr, () => T.of(true)),
|
||||
)();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
import { pipe, flow } from 'fp-ts/function';
|
||||
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 { User } from 'src/user/user.model';
|
||||
import {
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
} from 'src/errors';
|
||||
import { throwErr } from 'src/utils';
|
||||
|
||||
/**
|
||||
* This guard only allows the invitee to execute the resolver
|
||||
*
|
||||
* REQUIRES GqlAuthGuard
|
||||
*/
|
||||
@Injectable()
|
||||
export class TeamInviteeGuard implements CanActivate {
|
||||
constructor(private readonly teamInviteService: TeamInvitationService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// Get execution context
|
||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
||||
|
||||
// Get user
|
||||
TE.bindW('user', ({ gqlCtx }) =>
|
||||
pipe(
|
||||
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
|
||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
||||
),
|
||||
),
|
||||
|
||||
// Get invite
|
||||
TE.bindW('invite', ({ gqlCtx }) =>
|
||||
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
|
||||
TE.chainW(
|
||||
TE.fromPredicate(
|
||||
({ user, invite }) => user.email === invite.inviteeEmail,
|
||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
),
|
||||
),
|
||||
|
||||
// Fold it to a promise
|
||||
TE.fold(throwErr, () => T.of(true)),
|
||||
)();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { Team } from 'src/team/team.model';
|
||||
import { TeamInvitation } from './team-invitation.model';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
|
||||
@Resolver(() => Team)
|
||||
export class TeamTeamInviteExtResolver {
|
||||
constructor(private readonly teamInviteService: TeamInvitationService) {}
|
||||
|
||||
@ResolveField(() => [TeamInvitation], {
|
||||
description: 'Get all the active invites in the team',
|
||||
complexity: 10,
|
||||
})
|
||||
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
|
||||
return this.teamInviteService.getAllInvitationsInTeam(team)();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user