feat: wip migrate server to nestjs

This commit is contained in:
Ahmed Bouhuolia
2024-11-12 23:08:51 +02:00
parent f5834c72c6
commit 19080a67ab
94 changed files with 7587 additions and 98 deletions

View File

@@ -0,0 +1,32 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { TenancyContext } from './TenancyContext.service';
@Injectable()
export class EnsureTenantIsInitializedGuard implements CanActivate {
constructor(private readonly tenancyContext: TenancyContext) {}
/**
* Validate the tenant of the current request is initialized..
* @param {ExecutionContext} context
* @returns {Promise<boolean>}
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const tenant = await this.tenancyContext.getTenant();
if (!tenant?.initializedAt) {
throw new UnauthorizedException({
statusCode: 400,
error: 'Bad Request',
message: 'Tenant database is not migrated with application schema yet.',
errors: [{ type: 'TENANT.DATABASE.NOT.INITALIZED' }],
});
}
return true;
}
}

View File

@@ -0,0 +1,31 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { TenancyContext } from './TenancyContext.service';
@Injectable()
export class EnsureTenantIsSeededGuard implements CanActivate {
constructor(private readonly tenancyContext: TenancyContext) {}
/**
* Validate the tenant of the current request is seeded.
* @param {ExecutionContext} context
* @returns {Promise<boolean>}
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const tenant = await this.tenancyContext.getTenant();
if (!tenant.seededAt) {
throw new UnauthorizedException({
message: 'Tenant database is not seeded with initial data yet.',
errors: [{ type: 'TENANT.DATABASE.NOT.SEED' }],
});
}
return true;
}
}

View File

@@ -0,0 +1,18 @@
import type { RedisClientOptions } from 'redis';
import { DynamicModule, Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
interface TenancyCacheModuleConfig {
tenantId: number;
}
@Module({})
export class TenancyCacheModule {
static register(config: TenancyCacheModuleConfig): DynamicModule {
return {
module: TenancyCacheModule,
imports: [CacheModule.register<RedisClientOptions>({})],
};
}
}

View File

@@ -0,0 +1,39 @@
import { Inject, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { SystemUser } from '../System/models/SystemUser';
import { TenantModel } from '../System/models/TenantModel';
@Injectable()
export class TenancyContext {
constructor(
private readonly cls: ClsService,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@Inject(TenantModel.name)
private readonly systemTenantModel: typeof TenantModel,
) {}
/**
* Get the current tenant.
* @returns
*/
getTenant() {
// Get the tenant from the request headers.
const organizationId = this.cls.get('organizationId');
return this.systemTenantModel.query().findOne({ organizationId });
}
/**
*
* @returns
*/
getSystemUser() {
// Get the user from the request headers.
const userId = this.cls.get('userId');
return this.systemUserModel.query().findOne({ id: userId });
}
}

View File

@@ -0,0 +1 @@
export const TENANCY_DB_CONNECTION = 'TENANCY_DB_CONNECTION';

View File

@@ -0,0 +1,47 @@
import knex from 'knex';
import { Global, Module, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { TENANCY_DB_CONNECTION } from './TenancyDB.constants';
import { UnitOfWork } from './UnitOfWork.service';
import { knexSnakeCaseMappers } from 'objection';
import { ClsService } from 'nestjs-cls';
const connectionFactory = {
provide: TENANCY_DB_CONNECTION,
scope: Scope.REQUEST,
useFactory: async (
request: Request,
configService: ConfigService,
cls: ClsService,
) => {
const organizationId = cls.get('organizationId');
return knex({
client: configService.get('tenantDatabase.client'),
connection: {
host: configService.get('tenantDatabase.host'),
user: configService.get('tenantDatabase.user'),
password: configService.get('tenantDatabase.password'),
database: `bigcapital_tenant_${organizationId}`,
charset: 'utf8',
},
migrations: {
directory: configService.get('tenantDatabase.migrationDir'),
},
seeds: {
directory: configService.get('tenantDatabase.seedsDir'),
},
pool: { min: 0, max: 7 },
...knexSnakeCaseMappers({ upperCase: true }),
});
},
inject: [REQUEST, ConfigService, ClsService],
};
@Global()
@Module({
providers: [connectionFactory, UnitOfWork],
exports: [TENANCY_DB_CONNECTION, UnitOfWork],
})
export class TenancyDatabaseModule {}

View File

@@ -0,0 +1,58 @@
/**
* Enumeration that represents transaction isolation levels for use with the {@link Transactional} annotation
*/
export enum IsolationLevel {
/**
* A constant indicating that dirty reads, non-repeatable reads and phantom reads can occur.
*/
READ_UNCOMMITTED = 'read uncommitted',
/**
* A constant indicating that dirty reads are prevented; non-repeatable reads and phantom reads can occur.
*/
READ_COMMITTED = 'read committed',
/**
* A constant indicating that dirty reads and non-repeatable reads are prevented; phantom reads can occur.
*/
REPEATABLE_READ = 'repeatable read',
/**
* A constant indicating that dirty reads, non-repeatable reads and phantom reads are prevented.
*/
SERIALIZABLE = 'serializable',
}
/**
* @param {any} maybeTrx
* @returns {maybeTrx is import('objection').TransactionOrKnex & { executionPromise: Promise<any> }}
*/
function checkIsTransaction(maybeTrx) {
return Boolean(maybeTrx && maybeTrx.executionPromise);
}
/**
* Wait for a transaction to be complete.
* @param {import('objection').TransactionOrKnex} [trx]
*/
export async function waitForTransaction(trx) {
return Promise.resolve(checkIsTransaction(trx) ? trx.executionPromise : null);
}
/**
* Run a callback when the transaction is done.
* @param {import('objection').TransactionOrKnex | undefined} trx
* @param {Function} callback
*/
export function runAfterTransaction(trx, callback) {
waitForTransaction(trx).then(
() => {
// If transaction success, then run action
return Promise.resolve(callback()).catch((error) => {
setTimeout(() => {
throw error;
});
});
},
() => {
// Ignore transaction error
},
);
}

View File

@@ -0,0 +1,46 @@
import { Transaction } from 'objection';
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { IsolationLevel } from './TransactionsHooks';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
@Injectable()
export class UnitOfWork {
constructor(
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKex: Knex,
) {}
/**
*
* @param {number} tenantId
* @param {} work
* @param {IsolationLevel} isolationLevel
* @returns {}
*/
public withTransaction = async <T>(
work: (knex: Knex.Transaction) => Promise<T> | T,
trx?: Transaction,
isolationLevel: IsolationLevel = IsolationLevel.READ_UNCOMMITTED,
): Promise<T> => {
const knex = this.tenantKex;
let _trx = trx;
if (!_trx) {
_trx = await knex.transaction({ isolationLevel });
}
try {
const result = await work(_trx);
if (!trx) {
_trx.commit();
}
return result;
} catch (error) {
if (!trx) {
_trx.rollback();
}
throw error;
}
};
}

View File

@@ -0,0 +1,25 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { ClsService, ClsServiceManager } from 'nestjs-cls';
export class TenancyGlobalMiddleware implements NestMiddleware {
constructor(private readonly cls: ClsService) {}
/**
* Validates the organization ID in the request headers.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
public use(req: Request, res: Response, next: NextFunction) {
const organizationId = req.headers['organization-id'];
if (!organizationId) {
throw new UnauthorizedException('Organization ID is required.');
}
next();
}
}

View File

@@ -0,0 +1,23 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Observable } from 'rxjs';
@Injectable()
export class TenancyIdClsInterceptor implements NestInterceptor {
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const organizationId = request.headers['organization-id'];
// this.cls.get('organizationId');
// console.log(organizationId, 'organizationId22');
return next.handle();
}
}

View File

@@ -0,0 +1 @@
export const TenancyModelsConnection = 'TenancyModelsConnection';

View File

@@ -0,0 +1,24 @@
import { Knex } from 'knex';
import { Global, Module, Scope } from '@nestjs/common';
import { TENANCY_DB_CONNECTION } from '../TenancyDB/TenancyDB.constants';
import { Item } from '../../../modules/Items/models/Item';
import { Account } from '@/modules/Accounts/models/Account';
const models = [Item, Account];
const modelProviders = models.map((model) => {
return {
provide: model.name,
inject: [TENANCY_DB_CONNECTION],
scope: Scope.REQUEST,
useFactory: async (tenantKnex: Knex) => {
return model.bindKnex(tenantKnex);
},
};
});
@Global()
@Module({
providers: [...modelProviders],
exports: [...modelProviders],
})
export class TenancyModelsModule {}

View File

@@ -0,0 +1,7 @@
import { UseGuards } from '@nestjs/common';
import { EnsureTenantIsSeededGuard } from '../Tenancy/EnsureTenantIsSeeded.guards';
import { EnsureTenantIsInitializedGuard } from '../Tenancy/EnsureTenantIsInitialized.guard';
@UseGuards(EnsureTenantIsInitializedGuard)
@UseGuards(EnsureTenantIsSeededGuard)
export class TenantController {}