Skip to content

Commit

Permalink
Merge pull request #3889 from novuhq/stacked-tenants-support
Browse files Browse the repository at this point in the history
Stacked PR's Branch for Tenants Support
  • Loading branch information
djabarovgeorge authored Aug 1, 2023
2 parents 8674c25 + 0e92b1a commit b33bae4
Show file tree
Hide file tree
Showing 49 changed files with 981 additions and 35 deletions.
3 changes: 2 additions & 1 deletion apps/api/src/app/shared/dtos/pagination-response.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IPaginatedResponseDto } from '@novu/shared';

export class PaginatedResponseDto<T> {
export class PaginatedResponseDto<T> implements IPaginatedResponseDto<T> {
@ApiProperty({
description: 'The current page of the paginated response',
})
Expand Down
17 changes: 15 additions & 2 deletions apps/api/src/app/shared/framework/response.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {

return next.handle().pipe(
map((data) => {
// For paginated results that already contain the data wrapper, return the whole object
if (data?.data) {
if (this.returnWholeObject(data)) {
return {
...data,
data: isObject(data.data) ? this.transformResponse(data.data) : data.data,
Expand All @@ -31,6 +30,20 @@ export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {
);
}

/**
* This method is used to determine if the entire object should be returned or just the data property
* for paginated results that already contain the data wrapper, true.
* for single entity result that *could* contain data object, false.
* @param data
* @private
*/
private returnWholeObject(data) {
const isPaginatedResult = data?.data;
const isEntityObject = data?._id;

return isPaginatedResult && !isEntityObject;
}

private transformResponse(response) {
if (isArray(response)) {
return response.map((item) => this.transformToPlain(item));
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/app/tenant/dtos/create-tenant-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { TenantCustomData } from '@novu/shared';
import { ICreateTenantDto, TenantCustomData } from '@novu/shared';

export class CreateTenantRequestDto {
export class CreateTenantRequestDto implements ICreateTenantDto {
@ApiProperty()
identifier: string;

@ApiProperty()
name?: string;
name: string;

@ApiProperty()
data?: TenantCustomData;
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app/tenant/dtos/update-tenant-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { TenantCustomData } from '@novu/shared';
import { IUpdateTenantDto, TenantCustomData } from '@novu/shared';
import { IsOptional, IsString } from 'class-validator';

export class UpdateTenantRequestDto {
export class UpdateTenantRequestDto implements IUpdateTenantDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String })
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/app/tenant/e2e/create-tenant.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('Create Tenant - /tenants (POST)', function () {
expect(response.data).to.be.ok;

const createdTenant = await tenantRepository.findOne({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_123',
});
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/app/tenant/e2e/delete-tenant.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('Delete Tenant - /tenants/:identifier (DELETE)', function () {

it('should delete newly created tenant', async function () {
await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_123',
name: 'name_123',
Expand Down
13 changes: 8 additions & 5 deletions apps/api/src/app/tenant/e2e/get-tenant.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('Get Tenant - /tenants/:identifier (GET)', function () {

it('should get a newly created tenant', async function () {
await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_123',
name: 'name_123',
Expand Down Expand Up @@ -45,9 +46,11 @@ describe('Get Tenant - /tenants/:identifier (GET)', function () {
async function getTenant({ session, identifier }: { session; identifier: string }): Promise<AxiosResponse> {
const axiosInstance = axios.create();

return await axiosInstance.get(`${session.serverUrl}/v1/tenants/${identifier}`, {
headers: {
authorization: `ApiKey ${session.apiKey}`,
},
});
return (
await axiosInstance.get(`${session.serverUrl}/v1/tenants/${identifier}`, {
headers: {
authorization: `ApiKey ${session.apiKey}`,
},
})
).data;
}
11 changes: 7 additions & 4 deletions apps/api/src/app/tenant/e2e/get-tenants.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('Get Tenants List- /tenants (GET)', function () {
it('should get the newly created tenants', async function () {
for (let i = 0; i < 5; i++) {
await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: `identifier_${i}`,
name: 'name_123',
Expand All @@ -31,14 +32,15 @@ describe('Get Tenants List- /tenants (GET)', function () {
expect(data.pageSize).to.equal(10);
expect(data.hasMore).to.equal(false);
expect(data.data.length).to.equal(5);
expect(data.data[0].identifier).to.equal('identifier_0');
expect(data.data[4].identifier).to.equal('identifier_4');
expect(data.data[0].identifier).to.equal('identifier_4');
expect(data.data[4].identifier).to.equal('identifier_0');
});

it('should get second page of tenants', async function () {
for (let i = 0; i < 9; i++) {
await tenantRepository.create({
_environmentId: session.environment._id,
_organizationId: session.organization._id,
identifier: `identifier_${i}`,
name: 'name_123',
data: { test1: 'test value1', test2: 'test value2' },
Expand All @@ -53,14 +55,15 @@ describe('Get Tenants List- /tenants (GET)', function () {
expect(data.pageSize).to.equal(5);
expect(data.hasMore).to.equal(false);
expect(data.data.length).to.equal(4);
expect(data.data[0].identifier).to.equal('identifier_5');
expect(data.data[3].identifier).to.equal('identifier_8');
expect(data.data[0].identifier).to.equal('identifier_3');
expect(data.data[3].identifier).to.equal('identifier_0');
});

it('should get tenants by pagination', async function () {
for (let i = 0; i < 14; i++) {
await tenantRepository.create({
_environmentId: session.environment._id,
_organizationId: session.organization._id,
identifier: `identifier_${i}`,
name: 'name_123',
data: { test1: 'test value1', test2: 'test value2' },
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/app/tenant/e2e/update-tenant.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('Update Tenant - /tenants/:tenantId (PUT)', function () {

it('should update tenant', async function () {
await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_123',
name: 'name_123',
Expand Down Expand Up @@ -43,6 +44,7 @@ describe('Update Tenant - /tenants/:tenantId (PUT)', function () {

it('should not update identifier with null/undefined', async function () {
await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_123',
name: 'name_123',
Expand Down Expand Up @@ -78,11 +80,13 @@ describe('Update Tenant - /tenants/:tenantId (PUT)', function () {

it('should not be able to update to already existing identifier (in the same environment)', async function () {
await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_123',
});

await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_456',
});
Expand All @@ -105,6 +109,7 @@ describe('Update Tenant - /tenants/:tenantId (PUT)', function () {

it('should throw exception id tenant was not found under environment', async function () {
await tenantRepository.create({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
identifier: 'identifier_123',
});
Expand Down
12 changes: 7 additions & 5 deletions apps/api/src/app/tenant/tenant.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
Param,
Patch,
Post,
Put,
Query,
UseGuards,
UseInterceptors,
Expand Down Expand Up @@ -93,14 +92,14 @@ export class TenantController {
description: `Get tenant by your internal id used to identify the tenant`,
})
@ApiNotFoundResponse({
description: 'The tenant with the identifier provided does not exist in the database so it can not be deleted.',
description: 'The tenant with the identifier provided does not exist in the database.',
})
@ExternalApiAccessible()
getTenantById(
async getTenantById(
@UserSession() user: IJwtPayload,
@Param('identifier') identifier: string
): Promise<GetTenantResponseDto> {
return this.getTenantUsecase.execute(
return await this.getTenantUsecase.execute(
GetTenantCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
Expand All @@ -125,6 +124,7 @@ export class TenantController {
): Promise<CreateTenantResponseDto> {
return await this.createTenantUsecase.execute(
CreateTenantCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
identifier: body.identifier,
Expand All @@ -142,7 +142,7 @@ export class TenantController {
description: 'Update tenant by your internal id used to identify the tenant',
})
@ApiNotFoundResponse({
description: 'The tenant with the identifier provided does not exist in the database so it can not be deleted.',
description: 'The tenant with the identifier provided does not exist in the database.',
})
async updateTenant(
@UserSession() user: IJwtPayload,
Expand All @@ -151,6 +151,7 @@ export class TenantController {
): Promise<UpdateTenantResponseDto> {
return await this.updateTenantUsecase.execute(
UpdateTenantCommand.create({
userId: user._id,
identifier: identifier,
environmentId: user.environmentId,
organizationId: user.organizationId,
Expand Down Expand Up @@ -178,6 +179,7 @@ export class TenantController {
async removeTenant(@UserSession() user: IJwtPayload, @Param('identifier') identifier: string): Promise<void> {
return await this.deleteTenantUsecase.execute(
DeleteTenantCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
identifier: identifier,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { TenantCustomData } from '@novu/shared';
import { EnvironmentCommand } from '@novu/application-generic';
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

export class CreateTenantCommand extends EnvironmentCommand {
export class CreateTenantCommand extends EnvironmentWithUserCommand {
@IsString()
@IsNotEmpty()
identifier: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { ConflictException, Injectable } from '@nestjs/common';

import { TenantRepository } from '@novu/dal';
import { AnalyticsService } from '@novu/application-generic';

import { CreateTenantCommand } from './create-tenant.command';

@Injectable()
export class CreateTenant {
constructor(private tenantRepository: TenantRepository) {}
constructor(private tenantRepository: TenantRepository, private analyticsService: AnalyticsService) {}

async execute(command: CreateTenantCommand) {
const tenantExist = await this.tenantRepository.findOne({
Expand All @@ -18,11 +21,19 @@ export class CreateTenant {
);
}

return await this.tenantRepository.create({
const tenant = await this.tenantRepository.create({
_organizationId: command.organizationId,
_environmentId: command.environmentId,
identifier: command.identifier,
name: command.name,
data: command.data,
});

this.analyticsService.track('Create Tenant - [Tenants]', command.userId, {
_environmentId: command.environmentId,
_organization: command.organizationId,
});

return tenant;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EnvironmentCommand } from '@novu/application-generic';
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { IsNotEmpty, IsString } from 'class-validator';

export class DeleteTenantCommand extends EnvironmentCommand {
export class DeleteTenantCommand extends EnvironmentWithUserCommand {
@IsString()
@IsNotEmpty()
identifier: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class GetTenants {
{
limit: command.limit,
skip: command.page * command.limit,
sort: { createdAt: -1 },
}
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

import { EnvironmentCommand } from '@novu/application-generic';
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { TenantCustomData } from '@novu/shared';

export class UpdateTenantCommand extends EnvironmentCommand {
export class UpdateTenantCommand extends EnvironmentWithUserCommand {
@IsString()
@IsNotEmpty()
identifier: string;
Expand Down
59 changes: 59 additions & 0 deletions apps/web/cypress/tests/tenants-page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
describe('Tenants Page', function () {
beforeEach(function () {
cy.initializeSession().as('session');
});

it('should display empty tenants state', function () {
cy.visit('/tenants');

cy.getByTestId('no-tenant-placeholder').contains('Add the first tenant for the');
});

it('should add new tenant', function () {
createTenant();

cy.visit('/tenants');

cy.getByTestId('tenants-list-table').find('td:nth-child(1)').contains('Test Tenant');
cy.getByTestId('tenants-list-table').find('td:nth-child(2)').contains('test-tenant');
});

it('should update tenant', function () {
createTenant();

//update tenant name
cy.getByTestId('tenants-list-table')
.find('tr')
.eq(1)
.click()
.then(() => {
cy.getByTestId('tenant-name').clear().type('New Name');
cy.getByTestId('update-tenant-sidebar-submit').click();
});

cy.getByTestId('tenants-list-table').find('td:nth-child(1)').contains('New Name');
cy.getByTestId('tenants-list-table').find('td:nth-child(2)').contains('test-tenant');

//update tenant identifier
cy.getByTestId('tenants-list-table')
.find('tr')
.eq(1)
.click()
.then(() => {
cy.getByTestId('tenant-identifier').clear().type('new-identifier');
cy.getByTestId('update-tenant-sidebar-submit').click();
});

cy.getByTestId('tenants-list-table').find('td:nth-child(1)').contains('New Name');
cy.getByTestId('tenants-list-table').find('td:nth-child(2)').contains('new-identifier');
});

function createTenant() {
cy.visit('/tenants');

cy.getByTestId('add-tenant').click();
cy.getByTestId('tenant-name').type('Test Tenant');
cy.getByTestId('tenant-custom-properties').type('{"Org Name" : "Nike"}', { parseSpecialCharSequences: false });
cy.getByTestId('create-tenant-sidebar-submit').click();
}
});
Loading

1 comment on commit b33bae4

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.