feat: User operations on join org

This commit is contained in:
SauravDhakal
2026-03-04 22:26:20 +05:45
parent 024702dd26
commit 496d689ec1
22 changed files with 911 additions and 127 deletions

View File

@@ -44,12 +44,14 @@ export class CacheService {
// Fallback to DB
const fresh = await factory();
// Try setting cache only if Redis available
if (this.redisAvailable) {
try {
await this.cache.set(key, fresh, ttl);
} catch {
this.redisAvailable = false;
if (fresh) {
// Try setting cache only if Redis available
if (this.redisAvailable) {
try {
await this.cache.set(key, fresh, ttl);
} catch {
this.redisAvailable = false;
}
}
}

View File

@@ -2,13 +2,14 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const swaggerConfig = new DocumentBuilder()
.setTitle('Kaa Khane')
.setDescription(`API Documentation for Kaa Khane`)
.setTitle('MultiTenant Saas')
.setDescription(`API Documentation for a simple MultiTenant Saas Application`)
.setVersion('0.0.1')
.addGlobalResponse(
{
@@ -46,5 +47,7 @@ async function bootstrap() {
const port = config.get<number>('PORT') ?? 3000;
await app.listen(port);
Logger.log(`Listning on port ${port}`)
}
bootstrap();

View File

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

View File

@@ -0,0 +1,4 @@
export enum USER_ORG_ACCEPT_REJECT_ACTION {
ACCEPT = 'ACCEPT',
REJECT = 'REJECT'
}

View File

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

View File

@@ -12,15 +12,6 @@ export class InviteUserToOrganizationRequestDTO {
@IsNotEmpty()
invitedUserEmail: string;
@ApiProperty({
description: 'Organization id',
example: 'eeec2c79-766a-4174-8004-2e57642095fe',
type: 'string',
})
@IsUUID()
@IsNotEmpty()
orgId: string;
@ApiProperty({
description: 'Role to assign',
example: ORG_ROLE.member,

View File

@@ -0,0 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
export class JoinRequestToOrganizationRequestDTO {
@ApiPropertyOptional({
description: 'Message along with the request',
example: 'I would like to join',
type: 'string',
})
@IsString()
@IsOptional()
requestMessage?: string;
}

View File

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

View File

@@ -1,62 +1,145 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, ParseEnumPipe, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { OrganizationMembershipService } from './organization-membership.service';
import { RequestContextService } from 'core/als/request-context.service';
import { ApiBearerAuth } from '@nestjs/swagger';
import { InviteUserToOrganizationRequestDTO } from './dto';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
import { InviteUserToOrganizationRequestDTO, JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto';
import { ORGANIZATION_JOIN_REQUEST_TYPE } from 'prisma/generated/prisma/enums';
@Controller('organization-membership')
/* NOTE: Regarding endpoint path naming
* - Since we follow REST style, endpoint are resource based.
* - So insted of /organization/:orgId/invitation-action, we user ..../invitation/:invitationId
* (invitationid points to a resource)
* */
@Controller('membership')
@ApiBearerAuth('access-token')
export class OrganizationMembershipController {
constructor(
private readonly orgMemService: OrganizationMembershipService,
private readonly requestContext: RequestContextService,
) {}
) { }
@Post('/invite')
async inviteUserToOrg(@Body() body: InviteUserToOrganizationRequestDTO) {
const user = this.requestContext.user;
return await this.orgMemService.inviteUserToOrg(user.userId, body);
}
@Post('/request')
async requestToJoinOrg() {}
// TODO: Move invite to org. section and join to user. Also option to cancel invitation and join req.
/* *
* USER OPERATIONS
* */
@Patch('user/accept-invitation')
acceptInvitation() {}
@Patch('user/reject-invitation')
rejectInvitation() {}
@Get('user/invitations')
async getUserInvitations() {
@ApiOperation({ summary: 'Send request to join an organization' })
@ApiParam({
name: 'orgId',
type: String,
})
@Post('organization/:orgId/join-request')
async requestToJoinOrg(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
@Body() body: JoinRequestToOrganizationRequestDTO
) {
const user = this.requestContext.user;
return await this.orgMemService.getUserInvitations(user.userId);
return await this.orgMemService.usersRequestToJoin(user.userId, orgId, body)
}
@ApiOperation({ summary: 'Cancel a sent invitation to join an organization' })
@ApiParam({
name: 'orgId',
type: String,
})
@Delete('organization/:orgId/join-request')
async cancelRequestToJoinOrg(@Param('orgId', new ParseUUIDPipe()) orgId: string) {
const user = this.requestContext.user;
return await this.orgMemService.userCancelOrgJoinRequest(user.userId, orgId)
}
@ApiOperation({ summary: 'Accept or reject an invitation from an organization' })
@ApiParam({
name: 'orgId',
type: String,
})
@Patch('organization/:orgId/invitation')
async acceptOrRejectInvitation(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
@Body() body: UserOrganizationInvitationActionRequestDTO
) {
const user = this.requestContext.user;
return await this.orgMemService.userOrganiaztionRequestAction(
user.userId,
orgId,
body
)
}
@ApiOperation({ summary: 'List invitations recieved or join requests sent' })
@ApiQuery({
name: 'requestType',
enum: ORGANIZATION_JOIN_REQUEST_TYPE,
required: false,
})
@Get('me/invitations')
async getUserInvitations(
@Query(
'requestType',
new ParseEnumPipe(
ORGANIZATION_JOIN_REQUEST_TYPE, { optional: true }
)
) requestType?: ORGANIZATION_JOIN_REQUEST_TYPE
) {
const user = this.requestContext.user;
return await this.orgMemService.getUserInvitations(user.userId, requestType);
}
@ApiOperation({ summary: 'List organizations user is part of' })
@Get('me/organizations')
async getUserOrganizations() {
const user = this.requestContext.user;
return await this.orgMemService.getOrganizationsOfUser(user.userId)
}
@ApiOperation({ summary: 'Leave an organization' })
@ApiParam({
name: 'orgId',
type: String,
})
@Delete('organization/:orgId/member/me')
async leaveOrganization(
@Param('orgId', new ParseUUIDPipe()) orgId: string,
) {
const user = this.requestContext.user;
return await this.orgMemService.userLeaveAnOrganization(user.userId, orgId)
}
@Get('user/organizations')
async getUserOrganizations() {}
/* *
* ORGANIZATION OPERATIONS
* */
@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.getMemebersOfOrganization(orgId);
}
@Patch('organization/:id/accept-request')
acceptJoinRequest() {}
@Patch('organization/:id/reject-request')
rejectJoinRequest() {}
// @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() { }
}

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 } from './dto';
import { InviteUserToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto';
import { PrismaService } from 'src/prisma/prisma.service';
import {
ORGANIZATION_JOIN_REQUEST,
@@ -15,6 +15,8 @@ import {
import { AuthorizationService } from 'src/authorization/authorization.service';
import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations';
import { Prisma } from 'prisma/generated/prisma/client';
import { JoinRequestToOrganizationRequestDTO } from './dto/join-request.dto';
import { USER_ORG_ACCEPT_REJECT_ACTION } from './constants';
@Injectable()
export class OrganizationMembershipService {
@@ -23,14 +25,15 @@ export class OrganizationMembershipService {
private readonly orgService: OrganizationService,
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.organizationExists(dto.orgId),
this.orgService.findById(orgId),
this.userService.findByEmail(invitedUserEmail),
]);
@@ -41,7 +44,7 @@ export class OrganizationMembershipService {
await this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
orgId: dto.orgId,
orgId: orgId,
userId: invitedUser.id,
},
},
@@ -52,7 +55,7 @@ export class OrganizationMembershipService {
const canInviteUser = await this.authorization.canPerformOperation(
userId,
dto.orgId,
orgId,
USER_ORGANIZATION_OPERATIONS.INVITE_USERS,
);
if (!canInviteUser) throw new ForbiddenException('Insufficient Permission');
@@ -62,6 +65,7 @@ export class OrganizationMembershipService {
data: {
...rest,
userId: invitedUser.id,
orgId,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.INVITED,
},
});
@@ -77,63 +81,227 @@ export class OrganizationMembershipService {
}
}
requestToJoin() {}
// TODO: reject, rejectReason
async acceptInvitation(userId: string, orgId: string) {
const orgExists = await this.orgService.organizationExists(orgId);
if (!orgExists) throw new NotFoundException('Organization');
const [userAlreadyPart, isUserInvited] = await Promise.all([
this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
orgId,
userId,
},
},
}),
/* *
* USER OPERATIONS
* */
async usersRequestToJoin(
userId: string,
orgId: string,
dto: JoinRequestToOrganizationRequestDTO
) {
const [
orgExists,
invitationAlreadySent,
userAlreadyPartOf
] = await Promise.all([
this.orgService.findById(orgId),
this.prisma.organizationJoinRequest.findUnique({
where: {
userId_orgId: {
orgId,
userId,
orgId: orgId
},
status: ORGANIZATION_JOIN_REQUEST.PENDING,
status: ORGANIZATION_JOIN_REQUEST.PENDING
},
select: { orgId: true }
}),
]);
this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
orgId: orgId,
userId
}
},
select: { userId: true }
})
])
if (userAlreadyPart)
throw new BadRequestException('User already part of this organization');
if (!isUserInvited)
throw new BadRequestException(
'User has no invitations from this organization',
);
if (!orgExists)
throw new NotFoundException("Organization")
if (invitationAlreadySent)
throw new BadRequestException("Invitation to join this organization already sent")
if (userAlreadyPartOf)
throw new BadRequestException("User already part of the organization")
return await this.prisma.$transaction(async (tx) => {
await tx.organizationJoinRequest.update({
return await this.prisma.organizationJoinRequest.create({
data: {
orgId: orgId,
userId,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED,
requestMessage: dto.requestMessage,
}
})
}
async userCancelOrgJoinRequest(
userId: string,
orgId: string
) {
try {
await this.prisma.organizationJoinRequest.update({
where: {
userId_orgId: {
userId,
orgId,
orgId
},
},
data: {
status: ORGANIZATION_JOIN_REQUEST.ACCEPTED,
},
});
return await tx.organizationUserJoinTable.create({
data: {
orgId,
userId,
},
});
});
status: ORGANIZATION_JOIN_REQUEST.CANCELLED,
}
})
}
catch (err) {
throw new NotFoundException("Join request")
}
}
/*
* List of organizations that:
* - user have requested to join
* - have send invitations to user
*
* filtered by requestType
* */
async userOrganizationJoinRequestList(
userId: string,
requestType: string
) {
const joinReqType: ORGANIZATION_JOIN_REQUEST_TYPE | undefined = ORGANIZATION_JOIN_REQUEST_TYPE[requestType];
if (!joinReqType)
throw new BadRequestException("Invalid request type")
return await this.prisma.organizationJoinRequest.findMany({
where: {
userId,
requestType: joinReqType
}
})
}
async userOrganiaztionRequestAction(
userId: string,
orgId: 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
}
})
])
if (!orgExists)
throw new NotFoundException("Organization")
if (!hasUserSendRequest)
throw new BadRequestException("No pending join request")
try {
return await this.prisma.$transaction(async (tx) => {
const userAction = dto.action === USER_ORG_ACCEPT_REJECT_ACTION.ACCEPT
? ORGANIZATION_JOIN_REQUEST.ACCEPTED
: ORGANIZATION_JOIN_REQUEST.REJECTED;
await tx.organizationJoinRequest.update({
where: {
userId_orgId: {
userId,
orgId: orgId,
}
},
data: {
status: userAction,
...(
userAction === ORGANIZATION_JOIN_REQUEST.REJECTED && dto.message
? { rejectReason: dto.message }
: {}
)
}
});
if (userAction === ORGANIZATION_JOIN_REQUEST.ACCEPTED)
await tx.organizationUserJoinTable.create({
data: {
userId: userId,
orgId: orgId,
}
})
});
}
catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002')
throw new BadRequestException('User already part of this organization.');
} else {
throw err;
}
}
}
// TODO: reject, rejectReason
//
// async acceptInvitation(userId: string, orgId: string) {
// const orgExists = await this.orgService.organizationExists(orgId);
// if (!orgExists) throw new NotFoundException('Organization');
//
// const [userAlreadyPart, isUserInvited] = await Promise.all([
// this.prisma.organizationUserJoinTable.findUnique({
// where: {
// userId_orgId: {
// orgId,
// userId,
// },
// },
// }),
//
// this.prisma.organizationJoinRequest.findUnique({
// where: {
// userId_orgId: {
// orgId,
// userId,
// },
// status: ORGANIZATION_JOIN_REQUEST.PENDING,
// },
// }),
// ]);
//
// if (userAlreadyPart)
// throw new BadRequestException('User already part of this organization');
// if (!isUserInvited)
// throw new BadRequestException(
// 'User has no invitations from this organization',
// );
//
// return await this.prisma.$transaction(async (tx) => {
// await tx.organizationJoinRequest.update({
// where: {
// userId_orgId: {
// userId,
// orgId,
// },
// },
// data: {
// status: ORGANIZATION_JOIN_REQUEST.ACCEPTED,
// },
// });
//
// return await tx.organizationUserJoinTable.create({
// data: {
// orgId,
// userId,
// },
// });
// });
// }
async getUserInvitations(
userId: string,
requestType: ORGANIZATION_JOIN_REQUEST_TYPE = ORGANIZATION_JOIN_REQUEST_TYPE.INVITED,
@@ -153,8 +321,58 @@ export class OrganizationMembershipService {
});
}
async getOrganizationsOfUser(userId: string) {
return await this.prisma.organizationJoinRequest.findMany({
where: {
userId
},
include: {
organization: {
select: {
name: true,
description: true
}
}
},
})
}
async userLeaveAnOrganization(userId: string, orgId: string) {
try {
await this.prisma.organizationUserJoinTable.delete({
where: {
userId_orgId: { userId, orgId }
}
});
} catch (e) {
throw new NotFoundException("Membership not found");
}
return true;
}
async getOrganizationRequestList(
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 },
},
},
});
}
async getMemebersOfOrganization(orgId: string) {
const orgExists = await this.orgService.organizationExists(orgId);
const orgExists = await this.orgService.findById(orgId);
if (!orgExists) throw new NotFoundException('Organization');
return await this.prisma.organizationUserJoinTable.findMany({

View File

@@ -21,7 +21,7 @@ export class OrganizationService {
// private readonly reqContext: RequestContextService,
private readonly authorization: AuthorizationService,
private readonly cacheService: CacheService,
) {}
) { }
async createNewOrganization(
userId: string,
dto: CreateNewOrganizationRequestDTO,
@@ -60,7 +60,7 @@ export class OrganizationService {
orgId: string,
dto: UpdateOrganizationRequestDTO,
) {
const orgExists = await this.organizationExists(orgId);
const orgExists = await this.findById(orgId);
if (!orgExists) throw new NotFoundException('Organization');
const canUserUpdateOrganization =
@@ -83,7 +83,7 @@ export class OrganizationService {
// TODO: Either empty or choose someone to be owner
async deleteAnOrganization(userId: string, orgId: string) {
const orgExists = await this.organizationExists(orgId);
const orgExists = await this.findById(orgId);
if (!orgExists) throw new NotFoundException('Organization');
const canUserDeleteOrganization =
@@ -118,13 +118,16 @@ export class OrganizationService {
});
}
async organizationExists(orgId: string) {
return await this.prisma.organization.findUnique({
where: { id: orgId },
select: { id: true },
});
}
// async organizationExists(orgId: string) {
// return await this.prisma.organization.findUnique({
// where: { id: orgId },
// select: { id: true },
// });
// }
/*
* Its neat, it caches info plus only selects id so faster DB search as well
* */
async findById(orgId: string) {
const organization = await this.cacheService.getOrSet(
orgId,