feat: Added methods for organization

This commit is contained in:
SauravDhakal
2026-03-11 21:47:35 +05:45
parent 6fc494687a
commit 349196b801
24 changed files with 504 additions and 189 deletions

View File

@@ -0,0 +1,16 @@
import { applyDecorators, SetMetadata, UseGuards } from "@nestjs/common";
import { CAN_PERFORM_KEY } from "common/keys";
import { ORG_ROLE } from "prisma/generated/prisma/enums";
import { AuthorizationGuard } from "../guards";
/*
*Is this user part of the organization (And optionally, has required role)
* */
export function Authorization(role?: ORG_ROLE[]) {
return applyDecorators(
SetMetadata(CAN_PERFORM_KEY, role),
UseGuards(AuthorizationGuard)
)
}
//export const Authorization = (role?: ORG_ROLE[]) => SetMetadata(CAN_PERFORM_KEY, role)

View File

@@ -1,2 +1,3 @@
export * from './public.decorator';
export * from './role.decorator';
export * from './authorization.decorator';

View File

@@ -1,4 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { ROLE_KEY } from 'common/keys';
import { ORG_ROLE_KEY, ROLE_KEY } from 'common/keys';
export const Roles = (role: string) => SetMetadata(ROLE_KEY, role);
export const OrgRole = (role: string) => SetMetadata(ORG_ROLE_KEY, role);

View File

@@ -0,0 +1,57 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { CAN_PERFORM_KEY } from "common/keys";
import { RequestContextService } from "core/als/request-context.service";
import { ORG_ROLE } from "prisma/generated/prisma/enums";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(
private readonly reqeustContext: RequestContextService,
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) { };
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRole = this.reflector.getAllAndOverride<ORG_ROLE[] | undefined>(
CAN_PERFORM_KEY,
[context.getHandler(), context.getClass()]
)
const userId = this.reqeustContext.user.userId;
if (!userId)
throw new UnauthorizedException()
const request = context.switchToHttp().getRequest()
const orgId = request.params.orgId;
if (!orgId)
throw new BadRequestException()
const userIsPartOfOrg = await this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
userId,
orgId
},
...(requiredRole ? { role: { in: requiredRole } } : {})
},
select: {
userId: true
}
})
if (!userIsPartOfOrg)
throw new ForbiddenException()
this.reqeustContext.orgId = orgId;
return true;
}
}

View File

@@ -0,0 +1 @@
export * from "./authorization.guard"

View File

@@ -11,7 +11,7 @@ export class RbacGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly requestContext: RequestContextService,
) {}
) { }
canActivate(context: ExecutionContext) {
const requiredRole = this.reflector.getAllAndOverride<string>(ROLE_KEY, [
context.getHandler(),

View File

@@ -1,3 +1,4 @@
export * from './invite-to-org.dto';
export * from './join-request.dto'
export * from "./user-invitation-action.dto"
export * from "./org-request-action.dto"

View File

@@ -21,3 +21,14 @@ export class InviteUserToOrganizationRequestDTO {
@IsNotEmpty()
role: ORG_ROLE;
}
export class CancelInviteUserToOrganizationRequestDTO {
@ApiProperty({
description: 'Who to cancel',
example: 'bb1c81da-ce8f-4231-aee8-2b976124a589',
type: 'string',
})
@IsUUID()
@IsNotEmpty()
userId: string;
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
import { IsEnum, IsNotEmpty, IsOptional, IsString } from "class-validator"
import { USER_ORG_ACCEPT_REJECT_ACTION } from "../constants"
export class UserOrganizationRequestActionRequestDTO {
@ApiProperty({
description: 'Action',
example: USER_ORG_ACCEPT_REJECT_ACTION.ACCEPT,
type: 'string',
})
@IsEnum(USER_ORG_ACCEPT_REJECT_ACTION)
@IsNotEmpty()
action: USER_ORG_ACCEPT_REJECT_ACTION
@ApiPropertyOptional({
description: 'Message(reject reason)',
example: 'Bad sry or smth',
type: 'string',
})
@IsString()
@IsOptional()
message?: string
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
import { IsEnum, IsNotEmpty, IsOptional, IsString } from "class-validator";
import { USER_ORG_ACCEPT_REJECT_ACTION } from "../constants";

View File

@@ -2,8 +2,9 @@ import { Body, Controller, Delete, Get, Param, ParseEnumPipe, ParseUUIDPipe, Pat
import { OrganizationMembershipService } from './organization-membership.service';
import { RequestContextService } from 'core/als/request-context.service';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
import { JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto';
import { ORGANIZATION_JOIN_REQUEST_TYPE } from 'prisma/generated/prisma/enums';
import { CancelInviteUserToOrganizationRequestDTO, InviteUserToOrganizationRequestDTO, JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO, UserOrganizationRequestActionRequestDTO } from './dto';
import { ORG_ROLE, ORGANIZATION_JOIN_REQUEST_TYPE } from 'prisma/generated/prisma/enums';
import { Authorization } from 'src/auth/decorators';
/* NOTE: Regarding endpoint path naming
* - Since we follow REST style, endpoint are resource based.
@@ -42,10 +43,17 @@ export class OrganizationMembershipController {
name: 'orgId',
type: String,
})
@Delete('organization/:orgId/join-request')
async cancelRequestToJoinOrg(@Param('orgId', new ParseUUIDPipe()) orgId: string) {
@Delete('organization/:orgId/join-request/:id')
async cancelRequestToJoinOrg(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
@Param('id', new ParseUUIDPipe()) joinReqId: string
) {
const user = this.requestContext.user;
return await this.orgMemService.userCancelOrgJoinRequest(user.userId, orgId)
return await this.orgMemService.userCancelOrgJoinRequest(
user.userId,
orgId,
joinReqId
)
}
@ApiOperation({ summary: 'Accept or reject an invitation from an organization' })
@@ -53,15 +61,17 @@ export class OrganizationMembershipController {
name: 'orgId',
type: String,
})
@Patch('organization/:orgId/invitation')
@Patch('organization/:orgId/invitation/:id')
async acceptOrRejectInvitation(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
@Param('id', new ParseUUIDPipe()) invitationId: string,
@Body() body: UserOrganizationInvitationActionRequestDTO
) {
const user = this.requestContext.user;
return await this.orgMemService.userOrganiaztionRequestAction(
user.userId,
orgId,
invitationId,
body
)
}
@@ -110,36 +120,66 @@ export class OrganizationMembershipController {
* ORGANIZATION OPERATIONS
* */
// @ApiOperation({ summary: 'Invite user to organization' })
// @ApiParam({
// name: 'orgId',
// type: String,
// })
// @Post('organization/:orgId/invitation')
// async inviteUserToOrg(
// @Param('orgId', new ParseUUIDPipe()) orgId: string,
// @Body() body: InviteUserToOrganizationRequestDTO
// ) {
// const user = this.requestContext.user;
// return await this.orgMemService.inviteUserToOrg(
// user.userId,
// orgId,
// body
// );
// }
// @Get('organization/:id/members')
// async getOrganizationMemebers(@Param('id') orgId: string) {
// return await this.orgMemService.getMemebersOfOrganization(orgId);
// }
//
// @Get('organization/:id/invitations')
// async getOrganizationInvitations(@Param('id') orgId: string) {
// return await this.orgMemService.getOrganizationRequestList(orgId);
// }
//
// @Patch('organization/:id/accept-request')
// acceptJoinRequest() { }
//
// @Patch('organization/:id/reject-request')
// rejectJoinRequest() { }
@ApiOperation({ summary: 'Invite user to organization' })
@ApiParam({
name: 'orgId',
type: String,
})
@Authorization([ORG_ROLE.admin, ORG_ROLE.owner])
@Post('organization/:orgId/invitation')
async inviteUserToOrg(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
@Body() body: InviteUserToOrganizationRequestDTO
) {
return await this.orgMemService.inviteUserToOrg(
orgId,
body
);
}
@ApiOperation({ summary: 'Cancel a sent invitation to user' })
@ApiParam({
name: 'userId',
type: String,
})
@Authorization([ORG_ROLE.admin, ORG_ROLE.owner])
@Delete('organization/:orgId/invitation/:id')
async cancelInvitationsToUser(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
@Param('id', new ParseUUIDPipe()) invitationId: string,
@Body() body: CancelInviteUserToOrganizationRequestDTO
) {
const userId = body.userId;
return await this.orgMemService.orgCancelUserInviteRequest(userId, orgId, invitationId)
}
@Get('organization/:orgId/members')
@Authorization()
async getOrganizationMemebers(@Param('orgId') orgId: string) {
const user = this.requestContext.user;
return await this.orgMemService.getMemebersOfOrganization(user.userId, orgId);
}
@Get('organization/:orgId/invitations')
@Authorization([ORG_ROLE.admin, ORG_ROLE.owner])
async getOrganizationInvitations(@Param('orgId') orgId: string) {
const user = this.requestContext.user;
return await this.orgMemService.getOrganizationRequestList(user.userId, orgId);
}
@Patch('organization/:orgId/request/:id')
@Authorization([ORG_ROLE.admin, ORG_ROLE.owner])
async acceptOrRejectRequest(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
@Param('id', new ParseUUIDPipe()) invitationId: string,
@Body() body: UserOrganizationRequestActionRequestDTO
) {
const user = this.requestContext.user;
return await this.orgMemService.organizationUserJoinRequestAction(
user.userId,
orgId,
invitationId,
body
)
}
}

View File

@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { OrganizationService } from 'src/organization/organization.service';
import { UserService } from 'src/user/user.service';
import { InviteUserToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto';
import { InviteUserToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO, UserOrganizationRequestActionRequestDTO } from './dto';
import { PrismaService } from 'src/prisma/prisma.service';
import {
ORGANIZATION_JOIN_REQUEST,
@@ -26,60 +26,6 @@ export class OrganizationMembershipService {
private readonly prisma: PrismaService,
private readonly authorization: AuthorizationService,
) { }
async inviteUserToOrg(
userId: string,
orgId: string,
dto: InviteUserToOrganizationRequestDTO,
) {
const { invitedUserEmail, ...rest } = dto;
const [orgExists, invitedUser] = await Promise.all([
this.orgService.findById(orgId),
this.userService.findByEmail(invitedUserEmail),
]);
if (!orgExists) throw new NotFoundException('Organization');
if (!invitedUser) throw new NotFoundException('User');
const userAlreadyPart =
await this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
orgId: orgId,
userId: invitedUser.id,
},
},
});
if (userAlreadyPart)
throw new BadRequestException('User already part of this organization');
const canInviteUser = await this.authorization.canPerformOperation(
userId,
orgId,
USER_ORGANIZATION_OPERATIONS.INVITE_USERS,
);
if (!canInviteUser) throw new ForbiddenException('Insufficient Permission');
try {
const invitation = await this.prisma.organizationJoinRequest.create({
data: {
...rest,
userId: invitedUser.id,
orgId,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.INVITED,
},
});
return invitation;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002')
throw new BadRequestException('User invitation already sent.');
} else {
throw err;
}
}
}
/* *
* USER OPERATIONS
@@ -95,22 +41,17 @@ export class OrganizationMembershipService {
userAlreadyPartOf
] = await Promise.all([
this.orgService.findById(orgId),
this.prisma.organizationJoinRequest.findUnique({
this.prisma.organizationJoinRequest.findFirst({
where: {
userId_orgId: {
userId,
orgId: orgId
},
orgId,
userId,
status: ORGANIZATION_JOIN_REQUEST.PENDING
},
select: { orgId: true }
}),
this.prisma.organizationUserJoinTable.findUnique({
this.prisma.organizationUserJoinTable.findFirst({
where: {
userId_orgId: {
orgId: orgId,
userId
}
orgId,
userId,
},
select: { userId: true }
})
@@ -136,15 +77,15 @@ export class OrganizationMembershipService {
async userCancelOrgJoinRequest(
userId: string,
orgId: string
orgId: string,
id: string,
) {
try {
await this.prisma.organizationJoinRequest.update({
where: {
userId_orgId: {
userId,
orgId
},
id,
userId,
orgId
},
data: {
status: ORGANIZATION_JOIN_REQUEST.CANCELLED,
@@ -174,8 +115,9 @@ export class OrganizationMembershipService {
return await this.prisma.organizationJoinRequest.findMany({
where: {
userId,
requestType: joinReqType
}
requestType: joinReqType,
},
include: { user: { select: { firstName: true, email: true } } }
})
}
@@ -183,24 +125,19 @@ export class OrganizationMembershipService {
async userOrganiaztionRequestAction(
userId: string,
orgId: string,
id: string,
dto: UserOrganizationInvitationActionRequestDTO
) {
const [orgExists, hasUserSendRequest] = await Promise.all([
this.orgService.findById(orgId),
this.prisma.organizationJoinRequest.findUnique({
where: {
userId_orgId: {
userId,
orgId: orgId
},
status: ORGANIZATION_JOIN_REQUEST.PENDING,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED
}
})
])
const hasUserSendRequest = await this.prisma.organizationJoinRequest.findUnique({
where: {
id,
userId,
orgId,
status: ORGANIZATION_JOIN_REQUEST.PENDING,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED
}
})
if (!orgExists)
throw new NotFoundException("Organization")
if (!hasUserSendRequest)
throw new BadRequestException("No pending join request")
@@ -212,10 +149,9 @@ export class OrganizationMembershipService {
await tx.organizationJoinRequest.update({
where: {
userId_orgId: {
userId,
orgId: orgId,
}
id,
userId,
orgId,
},
data: {
status: userAction,
@@ -322,9 +258,9 @@ export class OrganizationMembershipService {
}
async getOrganizationsOfUser(userId: string) {
return await this.prisma.organizationJoinRequest.findMany({
return await this.prisma.organizationUserJoinTable.findMany({
where: {
userId
userId,
},
include: {
organization: {
@@ -351,41 +287,166 @@ export class OrganizationMembershipService {
return true;
}
async inviteUserToOrg(
orgId: string,
dto: InviteUserToOrganizationRequestDTO,
) {
const { invitedUserEmail, ...rest } = dto;
const invitedUser = await this.userService.findByEmail(invitedUserEmail);
if (!invitedUser) throw new NotFoundException('User');
const userAlreadyPart =
await this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
orgId: orgId,
userId: invitedUser.id,
},
},
});
if (userAlreadyPart)
throw new BadRequestException('User already part of this organization');
// TODO: Test in Authorization and remove
// const canInviteUser = await this.authorization.canPerformOperation(
// userId,
// orgId,
// USER_ORGANIZATION_OPERATIONS.INVITE_USERS,
// );
// if (!canInviteUser) throw new ForbiddenException('Insufficient Permission');
try {
const invitation = await this.prisma.organizationJoinRequest.create({
data: {
...rest,
userId: invitedUser.id,
orgId,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.INVITED,
},
});
return invitation;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002')
throw new BadRequestException('User invitation already sent.');
} else {
throw err;
}
}
}
async orgCancelUserInviteRequest(
userId: string,
orgId: string,
id: string,
) {
try {
return await this.prisma.organizationJoinRequest.update({
where: {
id,
userId,
orgId,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.INVITED,
status: ORGANIZATION_JOIN_REQUEST.PENDING
},
data: {
status: ORGANIZATION_JOIN_REQUEST.CANCELLED
}
})
}
catch (err) {
// TODO: Check error type and use it
throw new BadRequestException("Invitation not found")
}
}
async organizationUserJoinRequestAction(
userId: string,
orgId: string,
id: string,
dto: UserOrganizationRequestActionRequestDTO
) {
// NOTE: Experiment, don't know if its better
try {
return await this.prisma.$transaction(async (tx) => {
const updatedJoinReq = await tx.organizationJoinRequest.update({
where: {
id,
orgId,
status: ORGANIZATION_JOIN_REQUEST.PENDING,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED
},
data: {
status: dto.action === USER_ORG_ACCEPT_REJECT_ACTION.ACCEPT
? ORGANIZATION_JOIN_REQUEST.ACCEPTED
: ORGANIZATION_JOIN_REQUEST.REJECTED,
...((dto.action === USER_ORG_ACCEPT_REJECT_ACTION.REJECT && dto.message) ? {
rejectReason: dto.message
} : {})
}
});
console.log(updatedJoinReq)
return await tx.organizationUserJoinTable.create({
data: {
userId: updatedJoinReq.userId,
orgId,
}
})
})
}
catch (err) {
console.log(err);
throw new BadRequestException()
}
}
async getOrganizationRequestList(
userId: string,
orgId: string,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE = ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED,
status: ORGANIZATION_JOIN_REQUEST = ORGANIZATION_JOIN_REQUEST.PENDING,
) {
// TODO: Check can perform
return await this.prisma.organizationJoinRequest.findMany({
where: {
orgId: orgId,
status: status,
requestType: requestType,
},
include: {
user: {
select: { email: true },
},
},
});
orgId,
requestType: requestType
}
})
}
async getMemebersOfOrganization(orgId: string) {
const orgExists = await this.orgService.findById(orgId);
if (!orgExists) throw new NotFoundException('Organization');
return await this.prisma.organizationUserJoinTable.findMany({
async getMemebersOfOrganization(userId: string, orgId: string) {
const members = await this.prisma.organization.findFirst({
where: {
orgId: orgId,
id: orgId,
members: {
some: {
userId: userId
}
}
},
include: {
user: {
select: {
email: true,
members: {
include: {
user: {
select: {
firstName: true,
email: true
}
}
},
},
}
},
});
})
if (!members)
throw new NotFoundException("Organization")
return members;
}
}

View File

@@ -6,6 +6,7 @@ import {
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import {
CreateNewOrganizationRequestDTO,
@@ -16,6 +17,7 @@ import { OrganizationService } from './organization.service';
import { RequestContextService } from 'core/als/request-context.service';
import { ApiBearerAuth } from '@nestjs/swagger';
import { DataResponse } from 'common/http';
import { AuthorizationGuard } from 'src/auth/guards';
@Controller('organization')
@ApiBearerAuth('access-token')
@@ -23,7 +25,7 @@ export class OrganizationController {
constructor(
private readonly orgService: OrganizationService,
private readonly requestContext: RequestContextService,
) {}
) { }
@Post('')
async createNewOrganization(
@@ -50,17 +52,18 @@ export class OrganizationController {
);
}
@Get(':id')
@Get(':orgId')
@UseGuards(AuthorizationGuard)
async getAnOrganization(
@Param('id') id: string,
@Param('orgId') orgId: string,
): Promise<DataResponse<OrganizationDTO>> {
const organization = await this.orgService.getOrganizationById(id);
const organization = await this.orgService.getOrganizationById(orgId);
return new DataResponse(new OrganizationDTO(organization));
}
@Put(':id')
@Put(':orgId')
async updateAnOrganization(
@Param('id') orgId: string,
@Param('orgId') orgId: string,
@Body() body: UpdateOrganizationRequestDTO,
): Promise<DataResponse<OrganizationDTO>> {
const user = this.requestContext.user;
@@ -76,9 +79,9 @@ export class OrganizationController {
);
}
@Delete(':id')
@Delete(':orgId')
async deleteAnOrganization(
@Param('id') orgId: string,
@Param('orgId') orgId: string,
): Promise<DataResponse<OrganizationDTO>> {
const user = this.requestContext.user;
const deletedOrg = await this.orgService.deleteAnOrganization(