feat: Organization services

This commit is contained in:
sauravdhakal12
2026-02-22 15:47:45 +05:45
parent f4c9174752
commit afed1731d2
42 changed files with 862 additions and 17 deletions

View File

@@ -11,6 +11,9 @@ import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { ResponseInterceptor } from 'common/interceptors/response.interceptor';
import { ExceptionsHandler } from '@nestjs/core/exceptions/exceptions-handler';
import { HttpExceptionFilter } from 'common/exceptions/exception-filter';
import { OrganizationModule } from './organization/organization.module';
import { OrganizationMembershipModule } from './organization-membership/organization-membership.module';
import { AuthorizationModule } from './authorization/authorization.module';
@Module({
imports: [
@@ -21,6 +24,9 @@ import { HttpExceptionFilter } from 'common/exceptions/exception-filter';
AuthModule,
RequestContextModule,
PrismaModule,
OrganizationModule,
OrganizationMembershipModule,
AuthorizationModule,
],
controllers: [AppController],
providers: [

View File

@@ -15,8 +15,10 @@ import {
} from './dto';
import { Response } from 'express';
import { DataResponse } from 'common/http';
import { Public } from './decorators';
@Controller('auth')
@Public()
export class AuthController {
constructor(private readonly authService: AuthService) {}

View File

@@ -13,8 +13,7 @@ import { RequestContextModule } from 'core/als/request-context.module';
AuthService,
{
provide: APP_GUARD,
useFactory: () => AuthGuard,
inject: [Reflector],
useClass: AuthGuard,
},
],
controllers: [AuthController],

View File

@@ -1,6 +1,7 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { RequestContextService } from 'core/als/request-context.service';
@@ -10,6 +11,7 @@ import { Request } from 'express';
import { Reflector } from '@nestjs/core';
import { PUBLIC_KEY } from 'common/keys';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
@@ -30,7 +32,9 @@ export class AuthGuard implements CanActivate {
if (!token) throw new UnauthorizedException();
try {
const payload: JwtPayload = await this.jwtService.verifyAsync(token);
const payload: JwtPayload = await this.jwtService.verifyAsync(token, {
secret: 'demo',
});
this.requestContext.set('user', payload);
return true;

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthorizationService } from './authorization.service';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
providers: [AuthorizationService],
imports: [PrismaModule],
exports: [AuthorizationService],
})
export class AuthorizationModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthorizationService } from './authorization.service';
describe('AuthorizationService', () => {
let service: AuthorizationService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthorizationService],
}).compile();
service = module.get<AuthorizationService>(AuthorizationService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { USER_ORGANIZATION_OPERATIONS } from './operations';
import { ORG_ROLE } from 'prisma/generated/prisma/enums';
@Injectable()
export class AuthorizationService {
constructor(private readonly prisma: PrismaService) {}
// can perform operation
async canPerformOperation(
userId: string,
orgId: string,
operation: USER_ORGANIZATION_OPERATIONS,
) {
switch (operation) {
case USER_ORGANIZATION_OPERATIONS.DELETE_ORGANIZATION:
return await this.isOwner(userId, orgId);
case USER_ORGANIZATION_OPERATIONS.UPDATE_ORGANIZATION:
return await this.isOwner(userId, orgId);
case USER_ORGANIZATION_OPERATIONS.INVITE_USERS:
return await this.canInvite(userId, orgId);
}
}
private async isOwner(userId: string, orgId: string) {
const isUserPartOfOrganization = await this.isUserPartOfOrganization(
userId,
orgId,
);
return isUserPartOfOrganization
? isUserPartOfOrganization.role === ORG_ROLE.owner
: !!isUserPartOfOrganization;
}
private async canInvite(userId: string, orgId: string) {
const isUserPartOfOrganization = await this.isUserPartOfOrganization(
userId,
orgId,
);
return isUserPartOfOrganization
? isUserPartOfOrganization.role === ORG_ROLE.admin ||
isUserPartOfOrganization.role === ORG_ROLE.owner
: !!isUserPartOfOrganization;
}
private async isAdmin(userId: string, orgId: string) {
const isUserPartOfOrganization = await this.isUserPartOfOrganization(
userId,
orgId,
);
return isUserPartOfOrganization
? isUserPartOfOrganization.role === ORG_ROLE.admin
: !!isUserPartOfOrganization;
}
// HELPER FUNCTION
private async isUserPartOfOrganization(userId: string, orgId: string) {
return await this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
orgId,
userId,
},
},
});
}
}

View File

@@ -0,0 +1,5 @@
export enum USER_ORGANIZATION_OPERATIONS {
UPDATE_ORGANIZATION = 'update_organization',
DELETE_ORGANIZATION = 'delete_organization',
INVITE_USERS = 'invite_users',
}

View File

@@ -0,0 +1 @@
export * from './invite-to-org.dto';

View File

@@ -0,0 +1,16 @@
import { IsEnum, IsNotEmpty, IsUUID } from 'class-validator';
import { ORG_ROLE } from 'prisma/generated/prisma/enums';
export class InviteUserToOrganizationRequestDTO {
@IsUUID()
@IsNotEmpty()
invitedUserId: string;
@IsUUID()
@IsNotEmpty()
orgId: string;
@IsEnum(ORG_ROLE)
@IsNotEmpty()
role: ORG_ROLE;
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrganizationMembershipController } from './organization-membership.controller';
describe('OrganizationMembershipController', () => {
let controller: OrganizationMembershipController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OrganizationMembershipController],
}).compile();
controller = module.get<OrganizationMembershipController>(OrganizationMembershipController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';
@Controller('organization-membership')
export class OrganizationMembershipController {}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { OrganizationMembershipController } from './organization-membership.controller';
import { OrganizationMembershipService } from './organization-membership.service';
import { OrganizationModule } from 'src/organization/organization.module';
import { UserModule } from 'src/user/user.module';
import { PrismaModule } from 'src/prisma/prisma.module';
import { AuthorizationModule } from 'src/authorization/authorization.module';
@Module({
controllers: [OrganizationMembershipController],
providers: [OrganizationMembershipService],
imports: [OrganizationModule, UserModule, PrismaModule, AuthorizationModule],
})
export class OrganizationMembershipModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrganizationMembershipService } from './organization-membership.service';
describe('OrganizationMembershipService', () => {
let service: OrganizationMembershipService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OrganizationMembershipService],
}).compile();
service = module.get<OrganizationMembershipService>(OrganizationMembershipService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,148 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { OrganizationService } from 'src/organization/organization.service';
import { UserService } from 'src/user/user.service';
import { InviteUserToOrganizationRequestDTO } from './dto';
import { PrismaService } from 'src/prisma/prisma.service';
import {
ORGANIZATION_JOIN_REQUEST,
ORGANIZATION_JOIN_REQUEST_TYPE,
} from 'prisma/generated/prisma/enums';
import { AuthorizationService } from 'src/authorization/authorization.service';
import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations';
import { Prisma } from 'prisma/generated/prisma/client';
@Injectable()
export class OrganizationMembershipService {
constructor(
private readonly userService: UserService,
private readonly orgService: OrganizationService,
private readonly prisma: PrismaService,
private readonly authorization: AuthorizationService,
) {}
async inviteUserToOrg(
userId: string,
dto: InviteUserToOrganizationRequestDTO,
) {
const [orgExists, invitedUser] = await Promise.all([
this.orgService.findById(dto.orgId),
this.userService.getById(dto.invitedUserId),
]);
if (!orgExists) throw new NotFoundException('Organization');
if (!invitedUser) throw new NotFoundException('User');
const userAlreadyPart =
await this.prisma.organizationUserJoinTable.findUnique({
where: {
userId_orgId: {
orgId: dto.orgId,
userId,
},
},
});
if (userAlreadyPart)
throw new BadRequestException('User already part of this organization');
const canInviteUser = await this.authorization.canPerformOperation(
userId,
dto.orgId,
USER_ORGANIZATION_OPERATIONS.INVITE_USERS,
);
if (!canInviteUser) throw new ForbiddenException('Insufficient Permission');
try {
const invitation = await this.prisma.organizationJoinRequest.create({
data: {
...dto,
userId: dto.invitedUserId,
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.');
}
throw err;
}
}
requestToJoin() {}
// TODO: reject, rejectReason
async acceptInvite(userId: string, orgId: string) {
const orgExists = await this.orgService.findById(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,
status: ORGANIZATION_JOIN_REQUEST = ORGANIZATION_JOIN_REQUEST.PENDING,
) {
return await this.prisma.organizationJoinRequest.findMany({
where: {
userId: userId,
status: status,
requestType: requestType,
},
});
}
}

View File

@@ -0,0 +1,2 @@
export * from './organization.dto';
export * from './organization-response.dto';

View File

@@ -0,0 +1,15 @@
import { Organization } from 'prisma/generated/prisma/client';
export class OrganizationDTO {
readonly name: string;
readonly description: string | null;
readonly createdAt: Date;
constructor(organization: Organization) {
this.name = organization.name;
this.description = organization.description;
this.createdAt = organization.createdAt;
}
}
export class CreateNewOrganizationResponseDTO extends OrganizationDTO {}

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { AtLeastOneField } from 'common/validators';
export class CreateNewOrganizationRequestDTO {
@ApiProperty({
description: "Organization's name",
example: 'Lions',
type: 'string',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({
description: 'Short description for organization',
example: 'A cool organization active for over 10 years.',
type: 'string',
})
@IsOptional()
@IsString()
description?: string;
}
@AtLeastOneField({
message: 'Provide at least one field to update',
})
export class UpdateOrganizationRequestDTO extends PartialType(
CreateNewOrganizationRequestDTO,
) {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrganizationController } from './organization.controller';
describe('OrganizationController', () => {
let controller: OrganizationController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OrganizationController],
}).compile();
controller = module.get<OrganizationController>(OrganizationController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,94 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from '@nestjs/common';
import {
CreateNewOrganizationRequestDTO,
OrganizationDTO,
UpdateOrganizationRequestDTO,
} from './dtos';
import { OrganizationService } from './organization.service';
import { RequestContextService } from 'core/als/request-context.service';
import { ApiBearerAuth } from '@nestjs/swagger';
import { DataResponse } from 'common/http';
@Controller('organization')
@ApiBearerAuth('access-token')
export class OrganizationController {
constructor(
private readonly orgService: OrganizationService,
private readonly requestContext: RequestContextService,
) {}
@Post('')
async createNewOrganization(
@Body() body: CreateNewOrganizationRequestDTO,
): Promise<DataResponse<OrganizationDTO>> {
const user = this.requestContext.user;
const newOrg = await this.orgService.createNewOrganization(
user.userId,
body,
);
return new DataResponse<OrganizationDTO>(
new OrganizationDTO(newOrg),
'Organization created successfully.',
);
}
@Get('')
async getOrganizations(): Promise<DataResponse<OrganizationDTO[]>> {
const organizations = await this.orgService.getOrganizations();
return new DataResponse(
organizations.map((organization) => new OrganizationDTO(organization)),
);
}
@Get(':id')
async getAnOrganization(
@Param('id') id: string,
): Promise<DataResponse<OrganizationDTO>> {
const organization = await this.orgService.getOrganizationById(id);
return new DataResponse(new OrganizationDTO(organization));
}
@Put(':id')
async updateAnOrganization(
@Param('id') orgId: string,
@Body() body: UpdateOrganizationRequestDTO,
): Promise<DataResponse<OrganizationDTO>> {
const user = this.requestContext.user;
const updatedOrg = await this.orgService.updateAnOrganization(
user.userId,
orgId,
body,
);
return new DataResponse<OrganizationDTO>(
new OrganizationDTO(updatedOrg),
'Organization updated successfully',
);
}
@Delete(':id')
async deleteAnOrganization(
@Param('id') orgId: string,
): Promise<DataResponse<OrganizationDTO>> {
const user = this.requestContext.user;
const deletedOrg = await this.orgService.deleteAnOrganization(
user.userId,
orgId,
);
return new DataResponse<OrganizationDTO>(
new OrganizationDTO(deletedOrg),
'Organization deleted successfully',
);
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { RequestContextModule } from 'core/als/request-context.module';
import { UserModule } from 'src/user/user.module';
import { AuthorizationModule } from 'src/authorization/authorization.module';
@Module({
controllers: [OrganizationController],
providers: [OrganizationService],
imports: [
PrismaModule,
RequestContextModule,
UserModule,
AuthorizationModule,
],
exports: [OrganizationService],
})
export class OrganizationModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrganizationService } from './organization.service';
describe('OrganizationService', () => {
let service: OrganizationService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OrganizationService],
}).compile();
service = module.get<OrganizationService>(OrganizationService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,120 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CreateNewOrganizationRequestDTO,
UpdateOrganizationRequestDTO,
} from './dtos';
import { PrismaService } from 'src/prisma/prisma.service';
import { RequestContextService } from 'core/als/request-context.service';
import { ORG_ROLE } from 'prisma/generated/prisma/enums';
import { AuthorizationService } from 'src/authorization/authorization.service';
import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations';
@Injectable()
export class OrganizationService {
constructor(
private readonly prisma: PrismaService,
// private readonly reqContext: RequestContextService,
private readonly authorization: AuthorizationService,
) {}
async createNewOrganization(
userId: string,
dto: CreateNewOrganizationRequestDTO,
) {
return await this.prisma.$transaction(async (tx) => {
const newOrganization = await tx.organization.create({
data: dto,
});
await tx.organizationUserJoinTable.create({
data: {
orgId: newOrganization.id,
userId: userId,
role: ORG_ROLE.owner,
},
});
return newOrganization;
});
}
// NOTE: Pagination
async getOrganizations() {
return await this.prisma.organization.findMany();
}
async getOrganizationById(orgId: string) {
const orgExists = await this.findById(orgId);
if (!orgExists) throw new NotFoundException('Organization');
return orgExists;
}
async updateAnOrganization(
userId: string,
orgId: string,
dto: UpdateOrganizationRequestDTO,
) {
const orgExists = await this.findById(orgId);
if (!orgExists) throw new NotFoundException('Organization');
const canUserUpdateOrganization =
await this.authorization.canPerformOperation(
userId,
orgId,
USER_ORGANIZATION_OPERATIONS.UPDATE_ORGANIZATION,
);
if (!canUserUpdateOrganization)
throw new ForbiddenException('Not enough permission');
return this.prisma.organization.update({
where: {
id: orgId,
},
data: dto,
});
}
// TODO: Either empty or choose someone to be owner
async deleteAnOrganization(userId: string, orgId: string) {
const orgExists = await this.findById(orgId);
if (!orgExists) throw new NotFoundException('Organization');
const canUserDeleteOrganization =
await this.authorization.canPerformOperation(
userId,
orgId,
USER_ORGANIZATION_OPERATIONS.DELETE_ORGANIZATION,
);
if (!canUserDeleteOrganization)
throw new ForbiddenException('Not enough permission');
return await this.prisma.$transaction(async (tx) => {
const deletedOrganization = await tx.organization.delete({
where: {
id: orgId,
},
});
await tx.organizationUserJoinTable.delete({
where: {
userId_orgId: {
userId,
orgId,
},
},
});
return deletedOrganization;
});
}
async findById(orgId: string) {
return await this.prisma.organization.findUnique({ where: { id: orgId } });
}
}

View File

@@ -28,6 +28,14 @@ export class UserService {
});
}
async getById(id: string) {
return await this.prisma.user.findUnique({
where: {
id: id,
},
});
}
async updateRefreshToken(id: string, refreshToken: string) {
return await this.prisma.user.update({
where: { id },