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 { TeamEnvironmentsModule } from './team-environments/team-environments.module';
|
||||||
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
||||||
import { TeamRequestModule } from './team-request/team-request.module';
|
import { TeamRequestModule } from './team-request/team-request.module';
|
||||||
|
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,6 +54,7 @@ import { TeamRequestModule } from './team-request/team-request.module';
|
|||||||
TeamEnvironmentsModule,
|
TeamEnvironmentsModule,
|
||||||
TeamCollectionModule,
|
TeamCollectionModule,
|
||||||
TeamRequestModule,
|
TeamRequestModule,
|
||||||
|
TeamInvitationModule,
|
||||||
],
|
],
|
||||||
providers: [GQLComplexityPlugin],
|
providers: [GQLComplexityPlugin],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TeamMember } from 'src/team/team.model';
|
|||||||
import { TeamEnvironment } from 'src/team-environments/team-environments.model';
|
import { TeamEnvironment } from 'src/team-environments/team-environments.model';
|
||||||
import { TeamCollection } from 'src/team-collection/team-collection.model';
|
import { TeamCollection } from 'src/team-collection/team-collection.model';
|
||||||
import { TeamRequest } from 'src/team-request/team-request.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.
|
// 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.
|
// 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: `user_history/${string}/deleted_many`]: number;
|
||||||
[topic: `team_req/${string}/${'req_created' | 'req_updated'}`]: TeamRequest;
|
[topic: `team_req/${string}/${'req_created' | 'req_updated'}`]: TeamRequest;
|
||||||
[topic: `team_req/${string}/req_deleted`]: string;
|
[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