Compare commits

..

8 Commits

Author SHA1 Message Date
Mir Arif Hasan
089f6823e6 chore: removed stated return type 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
1d93745a4e chore: improved code readability 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
e0cc143436 chore: input-arg file added 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
b58acfe8dc feat: added all feedback 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
54bef30cf8 refactor: team invitation module 2023-07-13 11:55:28 +05:30
Balu Babu
6bc748a267 refactor: introduce team-environments into self-host refactored to pseudo-fp format (#3177) 2023-07-13 11:52:19 +05:30
Andrew Bastin
b29c04c28d fix: email not being checked case insensitive on team invitation acceptance (#3174) 2023-07-11 20:03:08 +05:30
Liyas Thomas
b2af353941 chore: new filled star icon to toggle favorite history entry (#3164) 2023-07-06 13:30:38 +05:30
20 changed files with 714 additions and 896 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

@@ -312,6 +312,13 @@ export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
*/ */
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const; export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
/**
* Invalid TEAM ENVIRONMENT name
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_SHORT_NAME =
'team_environment/short_name' as const;
/** /**
* The user is not a member of the team of the given environment * The user is not a member of the team of the given environment
* (GqlTeamEnvTeamGuard) * (GqlTeamEnvTeamGuard)

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

@@ -1,15 +1,5 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import * as TE from 'fp-ts/TaskEither';
import * as O from 'fp-ts/Option';
import * as S from 'fp-ts/string';
import { pipe } from 'fp-ts/function';
import {
getAnnotatedRequiredRoles,
getGqlArg,
getUserFromGQLContext,
throwErr,
} from 'src/utils';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
@@ -19,6 +9,10 @@ import {
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import * as E from 'fp-ts/Either';
import { TeamMemberRole } from '@prisma/client';
import { throwErr } from 'src/utils';
/** /**
* A guard which checks whether the caller of a GQL Operation * A guard which checks whether the caller of a GQL Operation
@@ -33,50 +27,31 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
private readonly teamService: TeamService, private readonly teamService: TeamService,
) {} ) {}
canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( const requireRoles = this.reflector.get<TeamMemberRole[]>(
TE.Do, 'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
TE.bindW('requiredRoles', () => const gqlExecCtx = GqlExecutionContext.create(context);
pipe(
getAnnotatedRequiredRoles(this.reflector, context),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
),
),
TE.bindW('user', () => const { user } = gqlExecCtx.getContext().req;
pipe( if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
getUserFromGQLContext(context),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
TE.bindW('envID', () => const { id } = gqlExecCtx.getArgs<{ id: string }>();
pipe( if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID);
getGqlArg('id', context),
O.fromPredicate(S.isString),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
),
),
TE.bindW('membership', ({ envID, user }) => const teamEnvironment =
pipe( await this.teamEnvironmentService.getTeamEnvironment(id);
this.teamEnvironmentService.getTeamEnvironment(envID), if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND);
TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND),
TE.chainW((env) =>
pipe(
this.teamService.getTeamMemberTE(env.teamID, user.uid),
TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER),
),
),
),
),
TE.map(({ membership, requiredRoles }) => const member = await this.teamService.getTeamMember(
requiredRoles.includes(membership.role), teamEnvironment.right.teamID,
), user.uid,
);
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
TE.getOrElse(throwErr), return requireRoles.includes(member.role);
)();
} }
} }

View File

@@ -0,0 +1,41 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
@ArgsType()
export class CreateTeamEnvironmentArgs {
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team',
})
teamID: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}
@ArgsType()
export class UpdateTeamEnvironmentArgs {
@Field(() => ID, {
name: 'id',
description: 'ID of the Team Environment',
})
id: string;
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}

View File

@@ -13,6 +13,11 @@ import { throwErr } from 'src/utils';
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import * as E from 'fp-ts/Either';
import {
CreateTeamEnvironmentArgs,
UpdateTeamEnvironmentArgs,
} from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => 'TeamEnvironment') @Resolver(() => 'TeamEnvironment')
@@ -29,29 +34,18 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createTeamEnvironment( async createTeamEnvironment(
@Args({ @Args() args: CreateTeamEnvironmentArgs,
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'teamID',
description: 'ID of the Team',
type: () => ID,
})
teamID: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return this.teamEnvironmentsService.createTeamEnvironment( const teamEnvironment =
name, await this.teamEnvironmentsService.createTeamEnvironment(
teamID, args.name,
variables, args.teamID,
)(); args.variables,
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
@@ -59,7 +53,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteTeamEnvironment( async deleteTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -67,10 +61,12 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<boolean> { ): Promise<boolean> {
return pipe( const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment(
this.teamEnvironmentsService.deleteTeamEnvironment(id), id,
TE.getOrElse(throwErr), );
)();
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return isDeleted.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -79,28 +75,19 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
updateTeamEnvironment( async updateTeamEnvironment(
@Args({ @Args()
name: 'id', args: UpdateTeamEnvironmentArgs,
description: 'ID of the Team Environment',
type: () => ID,
})
id: string,
@Args({
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const updatedTeamEnvironment =
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables), await this.teamEnvironmentsService.updateTeamEnvironment(
TE.getOrElse(throwErr), args.id,
)(); args.name,
args.variables,
);
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
return updatedTeamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -108,7 +95,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteAllVariablesFromTeamEnvironment( async deleteAllVariablesFromTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -116,10 +103,13 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const teamEnvironment =
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id), await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
TE.getOrElse(throwErr), id,
)(); );
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -127,7 +117,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createDuplicateEnvironment( async createDuplicateEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -135,10 +125,12 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const res = await this.teamEnvironmentsService.createDuplicateEnvironment(
this.teamEnvironmentsService.createDuplicateEnvironment(id), id,
TE.getOrElse(throwErr), );
)();
if (E.isLeft(res)) throwErr(res.left);
return res.right;
} }
/* Subscriptions */ /* Subscriptions */

View File

@@ -2,7 +2,11 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; import {
JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
@@ -31,125 +35,81 @@ beforeEach(() => {
describe('TeamEnvironmentsService', () => { describe('TeamEnvironmentsService', () => {
describe('getTeamEnvironment', () => { describe('getTeamEnvironment', () => {
test('queries the db with the id', async () => { test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: '123',
},
}),
); );
});
test('requests prisma to reject the query promise if not found', async () => { const result = await teamEnvironmentsService.getTeamEnvironment(
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); teamEnvironment.id,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
rejectOnNotFound: true,
}),
); );
expect(result).toEqualRight(teamEnvironment);
}); });
test('should return a Some of the correct environment if exists', async () => { test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment('123')(); const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
expect(result).toEqualSome(teamEnvironment); );
}); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
test('should return a None if the environment does not exist', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
expect(result).toBeNone();
}); });
}); });
describe('createTeamEnvironment', () => { describe('createTeamEnvironment', () => {
test('should create and return a new team environment given a valid name,variable and team ID', async () => { test('should successfully create and return a new team environment given valid inputs', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(result).toEqual(<TeamEnvironment>{ expect(result).toEqualRight({
id: teamEnvironment.id, ...teamEnvironment,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables), variables: JSON.stringify(teamEnvironment.variables),
}); });
}); });
test('should reject if given team ID is invalid', async () => { test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); const result = await teamEnvironmentsService.createTeamEnvironment(
'12',
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
);
await expect( expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
'invalidteamid',
JSON.stringify(teamEnvironment.variables),
),
).rejects.toBeDefined();
});
test('should reject if provided team environment name is not a string', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
null as any,
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
),
).rejects.toBeDefined();
});
test('should reject if provided variable is not a string', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
teamEnvironment.teamID,
null as any,
),
).rejects.toBeDefined();
}); });
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => { test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
result, {
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
},
); );
}); });
}); });
describe('deleteTeamEnvironment', () => { describe('deleteTeamEnvironment', () => {
test('should resolve to true given a valid team environment ID', async () => { test('should successfully delete a TeamEnvironment with a valid ID', async () => {
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(true); expect(result).toEqualRight(true);
}); });
@@ -159,7 +119,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
'invalidid', 'invalidid',
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -169,7 +129,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/deleted`, `team_environment/${teamEnvironment.teamID}/deleted`,
@@ -182,7 +142,7 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('updateVariablesInTeamEnvironment', () => { describe('updateVariablesInTeamEnvironment', () => {
test('should add new variable to a team environment', async () => { test('should successfully add new variable to a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }], variables: [{ key: 'value' }],
@@ -192,7 +152,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -200,7 +160,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should add new variable to already existing list of variables in a team environment', async () => { test('should successfully add new variable to already existing list of variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }, { key_2: 'value_2' }], variables: [{ key: 'value' }, { key_2: 'value_2' }],
@@ -210,7 +170,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -218,7 +178,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should edit existing variables in a team environment', async () => { test('should successfully edit existing variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '1234' }], variables: [{ key: '1234' }],
@@ -228,7 +188,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '1234' }]), JSON.stringify([{ key: '1234' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -236,22 +196,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should delete existing variable in a team environment', async () => { test('should successfully edit name of an existing team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{}]),
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
variables: JSON.stringify([{}]),
});
});
test('should edit name of an existing team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '123' }], variables: [{ key: '123' }],
@@ -261,7 +206,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '123' }]), JSON.stringify([{ key: '123' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -269,14 +214,24 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
'12',
JSON.stringify([{ key: 'value' }]),
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = await teamEnvironmentsService.updateTeamEnvironment( const result = await teamEnvironmentsService.updateTeamEnvironment(
'invalidid', 'invalidid',
teamEnvironment.name, teamEnvironment.name,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -288,7 +243,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -301,13 +256,13 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('deleteAllVariablesFromTeamEnvironment', () => { describe('deleteAllVariablesFromTeamEnvironment', () => {
test('should delete all variables in a team environment', async () => { test('should successfully delete all variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -315,13 +270,13 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
'invalidid', 'invalidid',
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -332,7 +287,7 @@ describe('TeamEnvironmentsService', () => {
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -345,7 +300,7 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('createDuplicateEnvironment', () => { describe('createDuplicateEnvironment', () => {
test('should duplicate an existing team environment', async () => { test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
teamEnvironment, teamEnvironment,
); );
@@ -357,21 +312,21 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables), variables: JSON.stringify(teamEnvironment.variables),
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -388,13 +343,13 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
{ {
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
variables: JSON.stringify([{}]), variables: JSON.stringify([{}]),
}, },
); );

View File

@@ -1,15 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function'; import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client';
import * as T from 'fp-ts/Task';
import * as TO from 'fp-ts/TaskOption';
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; import {
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils';
@Injectable() @Injectable()
export class TeamEnvironmentsService { export class TeamEnvironmentsService {
constructor( constructor(
@@ -17,219 +16,218 @@ export class TeamEnvironmentsService {
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) {} ) {}
getTeamEnvironment(id: string) { TITLE_LENGTH = 3;
return TO.tryCatch(() =>
this.prisma.teamEnvironment.findFirst({ /**
where: { id }, * TeamEnvironments are saved in the DB in the following way
* [{ key: value }, { key: value },....]
*
*/
/**
* Typecast a database TeamEnvironment to a TeamEnvironment model
* @param teamEnvironment database TeamEnvironment
* @returns TeamEnvironment model
*/
private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment {
return {
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables),
};
}
/**
* Get details of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironment(id: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a new TeamEnvironment.
*
* @param name name of new TeamEnvironment
* @param teamID teamID of new TeamEnvironment
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async createTeamEnvironment(name: string, teamID: string, variables: string) {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.create({
data: {
name: name,
teamID: teamID,
variables: JSON.parse(variables),
},
});
const createdTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${createdTeamEnvironment.teamID}/created`,
createdTeamEnvironment,
);
return E.right(createdTeamEnvironment);
}
/**
* Delete a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of boolean or error message
*/
async deleteTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.delete({
where: {
id: id,
},
});
const deletedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${deletedTeamEnvironment.teamID}/deleted`,
deletedTeamEnvironment,
);
return E.right(true);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Update a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @param name TeamEnvironment name
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async updateTeamEnvironment(id: string, name: string, variables: string) {
try {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
});
const updatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${updatedTeamEnvironment.teamID}/updated`,
updatedTeamEnvironment,
);
return E.right(updatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Clear contents of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async deleteAllVariablesFromTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
});
const teamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${teamEnvironment.teamID}/updated`,
teamEnvironment,
);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a duplicate of a existing TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirst({
where: {
id: id,
},
rejectOnNotFound: true, rejectOnNotFound: true,
}), });
);
const result = await this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
});
const duplicatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${duplicatedTeamEnvironment.teamID}/created`,
duplicatedTeamEnvironment,
);
return E.right(duplicatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
} }
createTeamEnvironment(name: string, teamID: string, variables: string) { /**
return pipe( * Fetch all TeamEnvironments of a team.
() => *
this.prisma.teamEnvironment.create({ * @param teamID teamID of new TeamEnvironment
data: { * @returns List of TeamEnvironments
name: name, */
teamID: teamID, async fetchAllTeamEnvironments(teamID: string) {
variables: JSON.parse(variables), const result = await this.prisma.teamEnvironment.findMany({
}, where: {
}), teamID: teamID,
T.chainFirst( },
(environment) => () => });
this.pubsub.publish( const teamEnvironments = result.map((item) => {
`team_environment/${environment.teamID}/created`, return this.cast(item);
<TeamEnvironment>{ });
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
T.map((data) => {
return <TeamEnvironment>{
id: data.id,
name: data.name,
teamID: data.teamID,
variables: JSON.stringify(data.variables),
};
}),
);
}
deleteTeamEnvironment(id: string) { return teamEnvironments;
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.delete({
where: {
id: id,
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/deleted`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map((data) => true),
);
}
updateTeamEnvironment(id: string, name: string, variables: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/updated`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
deleteAllVariablesFromTeamEnvironment(id: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/updated`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
createDuplicateEnvironment(id: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.findFirst({
where: {
id: id,
},
rejectOnNotFound: true,
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chain((environment) =>
TE.fromTask(() =>
this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
}),
),
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/created`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
fetchAllTeamEnvironments(teamID: string) {
return pipe(
() =>
this.prisma.teamEnvironment.findMany({
where: {
teamID: teamID,
},
}),
T.map(
A.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
);
} }
/** /**

View File

@@ -11,6 +11,6 @@ export class TeamEnvsTeamResolver {
description: 'Returns all Team Environments for the given Team', description: 'Returns all Team Environments for the given Team',
}) })
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> { teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)(); return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id);
} }
} }

View File

@@ -0,0 +1,20 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
import { TeamMemberRole } from 'src/team/team.model';
@ArgsType()
export class CreateTeamInvitationArgs {
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team ID to invite from',
})
teamID: string;
@Field({ name: 'inviteeEmail', description: 'Email of the user to invite' })
inviteeEmail: string;
@Field(() => TeamMemberRole, {
name: 'inviteeRole',
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole;
}

View File

@@ -12,15 +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 { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors';
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 { GqlUser } from 'src/decorators/gql-user.decorator';
import { User } from 'src/user/user.model'; import { User } from 'src/user/user.model';
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
@@ -36,6 +31,8 @@ import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AuthUser } from 'src/types/AuthUser';
import { CreateTeamInvitationArgs } from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => TeamInvitation) @Resolver(() => TeamInvitation)
@@ -79,8 +76,8 @@ 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: AuthUser,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
description: 'ID of the Team Invitation to lookup', description: 'ID of the Team Invitation to lookup',
@@ -88,17 +85,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,56 +97,19 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
createTeamInvitation( async createTeamInvitation(
@GqlUser() @GqlUser() user: AuthUser,
user: User, @Args() args: CreateTeamInvitationArgs,
@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> { ): Promise<TeamInvitation> {
return pipe( const teamInvitation = await this.teamInvitationService.createInvitation(
TE.Do, user,
args.teamID,
args.inviteeEmail,
args.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 +117,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,19 +125,19 @@ 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: AuthUser,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -191,10 +145,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,27 +1,25 @@
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 { 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 { 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,
} 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';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { validateEmail } from '../utils'; import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser';
@Injectable() @Injectable()
export class TeamInvitationService { export class TeamInvitationService {
@@ -32,38 +30,37 @@ export class TeamInvitationService {
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) { ) {}
this.getInvitation = this.getInvitation.bind(this);
/**
* Cast a DBTeamInvitation to a TeamInvitation
* @param dbTeamInvitation database TeamInvitation
* @returns TeamInvitation model
*/
cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
return {
...dbTeamInvitation,
inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
};
} }
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> { /**
return pipe( * Get the team invite
() => * @param inviteID invite id
this.prisma.teamInvitation.findUnique({ * @returns an Option of team invitation or none
where: { */
id: inviteID, async getInvitation(inviteID: string) {
}, try {
}), const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
TO.fromTask, where: {
TO.chain(flow(O.fromNullable, TO.fromOption)), id: inviteID,
TO.map((x) => x as TeamInvitation), },
); });
}
getInvitationWithEmail(email: Email, team: Team) { return O.some(this.cast(dbInvitation));
return pipe( } catch (e) {
() => return O.none;
this.prisma.teamInvitation.findUnique({ }
where: {
teamID_inviteeEmail: {
inviteeEmail: email,
teamID: team.id,
},
},
}),
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)),
);
} }
/** /**
@@ -92,211 +89,162 @@ export class TeamInvitationService {
} }
} }
createInvitation( /**
creator: User, * Create a team invitation
team: Team, * @param creator creator of the invitation
inviteeEmail: Email, * @param teamID team id
* @param inviteeEmail invitee email
* @param inviteeRole invitee role
* @returns an Either of team invitation or error message
*/
async createInvitation(
creator: AuthUser,
teamID: string,
inviteeEmail: string,
inviteeRole: TeamMemberRole, inviteeRole: TeamMemberRole,
) { ) {
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 isTeamMember = 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 (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
revokeInvitation(inviteID: string) { // Checking to see if the invitee is already part of the team or not
return pipe( const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
// Make sure invite exists if (O.isSome(inviteeUser)) {
this.getInvitation(inviteID), // invitee should not already a member
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), const isTeamMember = await this.teamService.getTeamMember(
team.id,
inviteeUser.value.uid,
);
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// Delete team invitation // check invitee already invited earlier or not
TE.chainTaskK( const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
() => () => inviteeEmail,
this.prisma.teamInvitation.delete({ team.id,
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),
); );
} if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE);
getAllInvitationsInTeam(team: Team) { // create the invitation
return pipe( const dbInvitation = await this.prisma.teamInvitation.create({
() => data: {
this.prisma.teamInvitation.findMany({ teamID: team.id,
where: { inviteeEmail,
teamID: team.id, inviteeRole,
}, creatorUid: creator.uid,
}), },
T.map((x) => x as TeamInvitation[]), });
);
}
acceptInvitation(inviteID: string, acceptedBy: User) { await this.mailerService.sendEmail(inviteeEmail, {
return pipe( template: 'team-invitation',
TE.Do, variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name,
},
});
// First get the invitation const invitation = this.cast(dbInvitation);
TE.bindW('invitation', () => this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
pipe(
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
// Validation checks return E.right(invitation);
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),
);
} }
/** /**
* Fetch the count invitations for a given team. * Revoke a team invitation
* @param teamID team id * @param inviteID invite id
* @returns a count team invitations for a team * @returns an Either of true or error message
*/ */
async getAllTeamInvitations(teamID: string) { async revokeInvitation(inviteID: string) {
const invitations = await this.prisma.teamInvitation.findMany({ // check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// delete the invite
await this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
});
this.pubsub.publish(
`team/${invitation.value.teamID}/invite_removed`,
invitation.value.id,
);
return E.right(true);
}
/**
* Accept a team invitation
* @param inviteID invite id
* @param acceptedBy user who accepted the invitation
* @returns an Either of team member or error message
*/
async acceptInvitation(inviteID: string, acceptedBy: AuthUser) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// 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);
// make sure the user is the same as the invitee
if (
acceptedBy.email.toLowerCase() !==
invitation.value.inviteeEmail.toLowerCase()
)
return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
// add the user to the team
let teamMember: TeamMember;
try {
teamMember = await this.teamService.addMemberToTeam(
invitation.value.teamID,
acceptedBy.uid,
invitation.value.inviteeRole,
);
} catch (e) {
return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// delete the invite
await this.revokeInvitation(inviteID);
return E.right(teamMember);
}
/**
* Fetch all team invitations for a given team.
* @param teamID team id
* @returns array of team invitations for a team
*/
async getTeamInvitations(teamID: string) {
const dbInvitations = await this.prisma.teamInvitation.findMany({
where: { where: {
teamID: teamID, teamID: teamID,
}, },
}); });
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
this.cast(dbInvitation),
);
return invitations; return invitations;
} }
} }

View File

@@ -1,21 +1,21 @@
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';
/**
* This guard only allows team owner to execute the resolver
*/
@Injectable() @Injectable()
export class TeamInviteTeamOwnerGuard implements CanActivate { export class TeamInviteTeamOwnerGuard implements CanActivate {
constructor( constructor(
@@ -24,48 +24,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,20 +1,23 @@
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';
/**
* This guard only allows user to execute the resolver
* 1. If user is invitee, allow
* 2. Or else, if user is team member, allow
*
* TLDR: Allow if user is invitee or team member
*/
@Injectable() @Injectable()
export class TeamInviteViewerGuard implements CanActivate { export class TeamInviteViewerGuard implements CanActivate {
constructor( constructor(
@@ -23,50 +26,32 @@ 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 user is a team member
// any better solution ? if (
TE.chainW(({ user, invite }) => user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase() ) {
? TE.of(true) const teamMember = await this.teamService.getTeamMember(
: pipe( invitation.value.teamID,
this.teamService.getTeamMemberTE(invite.teamID, user.uid), user.uid,
TE.map(() => true), );
),
),
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,11 +1,7 @@
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 { User } from 'src/user/user.model';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
@@ -24,44 +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 }) => user.email === invite.inviteeEmail, throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
() => 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);
} }
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -63,7 +63,7 @@ import { GQLHistoryEntry } from "~/newstore/history"
import { shortDateTime } from "~/helpers/utils/date" import { shortDateTime } from "~/helpers/utils/date"
import IconStar from "~icons/lucide/star" import IconStar from "~icons/lucide/star"
import IconStarOff from "~icons/lucide/star-off" import IconStarOff from "~icons/hopp/star-off"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
import IconMinimize2 from "~icons/lucide/minimize-2" import IconMinimize2 from "~icons/lucide/minimize-2"
import IconMaximize2 from "~icons/lucide/maximize-2" import IconMaximize2 from "~icons/lucide/maximize-2"

View File

@@ -55,7 +55,7 @@ import { RESTHistoryEntry } from "~/newstore/history"
import { shortDateTime } from "~/helpers/utils/date" import { shortDateTime } from "~/helpers/utils/date"
import IconStar from "~icons/lucide/star" import IconStar from "~icons/lucide/star"
import IconStarOff from "~icons/lucide/star-off" import IconStarOff from "~icons/hopp/star-off"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
const props = defineProps<{ const props = defineProps<{