feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,49 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
UnauthorizedException,
} from '@nestjs/common';
import { TenancyContext } from './TenancyContext.service';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
export const IS_IGNORE_TENANT_INITIALIZED = 'IS_IGNORE_TENANT_INITIALIZED';
export const IgnoreTenantInitializedRoute = () => SetMetadata(IS_IGNORE_TENANT_INITIALIZED, true);
@Injectable()
export class EnsureTenantIsInitializedGuard implements CanActivate {
constructor(private readonly tenancyContext: TenancyContext, private reflector: Reflector) {}
/**
* Validate the tenant of the current request is initialized..
* @param {ExecutionContext} context
* @returns {Promise<boolean>}
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const isIgnoreEnsureTenantInitialized = this.reflector.getAllAndOverride<boolean>(
IS_IGNORE_TENANT_INITIALIZED,
[context.getHandler(), context.getClass()],
);
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],
);
// Skip the guard early if the route marked as public or ignored.
if (isPublic || isIgnoreEnsureTenantInitialized) {
return true;
}
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,47 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
SetMetadata,
UnauthorizedException,
} from '@nestjs/common';
import { TenancyContext } from './TenancyContext.service';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
export const IS_IGNORE_TENANT_SEEDED = 'IS_IGNORE_TENANT_SEEDED';
export const IgnoreTenantSeededRoute = () => SetMetadata(IS_IGNORE_TENANT_SEEDED, true);
@Injectable()
export class EnsureTenantIsSeededGuard implements CanActivate {
constructor(private readonly tenancyContext: TenancyContext, private reflector: Reflector) {}
/**
* Validate the tenant of the current request is seeded.
* @param {ExecutionContext} context
* @returns {Promise<boolean>}
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],
);
const isIgnoreEnsureTenantSeeded = this.reflector.getAllAndOverride<boolean>(
IS_IGNORE_TENANT_SEEDED,
[context.getHandler(), context.getClass()],
);
if (isPublic || isIgnoreEnsureTenantSeeded) {
return true;
}
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,29 @@
import { Module } from "@nestjs/common";
import { EnsureTenantIsInitializedGuard } from "./EnsureTenantIsInitialized.guard";
import { TenancyGlobalGuard } from "./TenancyGlobal.guard";
import { EnsureTenantIsSeededGuard } from "./EnsureTenantIsSeeded.guards";
import { APP_GUARD } from "@nestjs/core";
import { TenancyContext } from "./TenancyContext.service";
import { TenantController } from "./Tenant.controller";
@Module({
exports: [TenancyContext],
controllers: [TenantController],
providers: [
TenancyContext,
{
provide: APP_GUARD,
useClass: TenancyGlobalGuard,
},
{
provide: APP_GUARD,
useClass: EnsureTenantIsInitializedGuard,
},
{
provide: APP_GUARD,
useClass: EnsureTenantIsSeededGuard
}
]
})
export class TenancyModule {}

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,54 @@
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.
* @param {boolean} withMetadata - If true, the tenant metadata will be fetched.
* @returns
*/
getTenant(withMetadata: boolean = false) {
// Get the tenant from the request headers.
const organizationId = this.cls.get('organizationId');
if (!organizationId) {
throw new Error('Tenant not found');
}
const query = this.systemTenantModel.query().findOne({ organizationId });
if (withMetadata) {
query.withGraphFetched('metadata');
}
return query;
}
async getTenantMetadata() {
const tenant = await this.getTenant(true);
return tenant?.metadata;
}
/**
* Retrieves the current system user.
* @returns {Promise<SystemUser>}
*/
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,56 @@
import knex from 'knex';
import * as LRUCache from 'lru-cache';
import { Global, Module } from '@nestjs/common';
import { knexSnakeCaseMappers } from 'objection';
import { ClsModule, ClsService } from 'nestjs-cls';
import { ConfigService } from '@nestjs/config';
import { TENANCY_DB_CONNECTION } from './TenancyDB.constants';
import { UnitOfWork } from './UnitOfWork.service';
const lruCache = new LRUCache();
export const TenancyDatabaseProxyProvider = ClsModule.forFeatureAsync({
provide: TENANCY_DB_CONNECTION,
global: true,
strict: true,
inject: [ConfigService, ClsService],
useFactory: async (configService: ConfigService, cls: ClsService) => () => {
const organizationId = cls.get('organizationId');
const database = `bigcapital_tenant_${organizationId}`;
const cachedInstance = lruCache.get(database);
if (cachedInstance) {
return cachedInstance;
}
const knexInstance = knex({
client: configService.get('tenantDatabase.client'),
connection: {
host: configService.get('tenantDatabase.host'),
user: configService.get('tenantDatabase.user'),
password: configService.get('tenantDatabase.password'),
database,
charset: 'utf8',
},
migrations: {
directory: configService.get('tenantDatabase.migrationsDir'),
},
seeds: {
directory: configService.get('tenantDatabase.seedsDir'),
},
pool: { min: 0, max: 7 },
...knexSnakeCaseMappers({ upperCase: true }),
});
lruCache.set(database, knexInstance);
return knexInstance;
},
type: 'function',
});
@Global()
@Module({
imports: [TenancyDatabaseProxyProvider],
providers: [UnitOfWork],
exports: [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,44 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
export const IS_TENANT_AGNOSTIC = 'IS_TENANT_AGNOSTIC';
export const TenantAgnosticRoute = () => SetMetadata(IS_TENANT_AGNOSTIC, true);
@Injectable()
export class TenancyGlobalGuard implements CanActivate {
constructor(private reflector: Reflector) {}
/**
* Validates the organization ID in the request headers.
* @param {ExecutionContext} context
* @returns {boolean}
*/
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const organizationId = request.headers['organization-id'];
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],
)
const isTenantAgnostic = this.reflector.getAllAndOverride<boolean>(
IS_TENANT_AGNOSTIC,
[context.getHandler(), context.getClass()],
);
if (isPublic || isTenantAgnostic) {
return true;
}
if (!organizationId) {
throw new UnauthorizedException('Organization ID is required.');
}
return true;
}
}

View File

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

View File

@@ -0,0 +1,110 @@
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.model';
import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry';
import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model';
import { Expense } from '@/modules/Expenses/models/Expense.model';
import { ExpenseCategory } from '@/modules/Expenses/models/ExpenseCategory.model';
import { ItemCategory } from '@/modules/ItemCategories/models/ItemCategory.model';
import { TaxRateModel } from '@/modules/TaxRates/models/TaxRate.model';
import { PdfTemplateModel } from '@/modules/PdfTemplate/models/PdfTemplate';
import { Warehouse } from '@/modules/Warehouses/models/Warehouse.model';
import { ItemWarehouseQuantity } from '@/modules/Warehouses/models/ItemWarehouseQuantity';
import { Branch } from '@/modules/Branches/models/Branch.model';
import { SaleEstimate } from '@/modules/SaleEstimates/models/SaleEstimate';
import { Customer } from '@/modules/Customers/models/Customer';
import { Contact } from '@/modules/Contacts/models/Contact';
import { Document } from '@/modules/ChromiumlyTenancy/models/Document';
import { DocumentLink } from '@/modules/ChromiumlyTenancy/models/DocumentLink';
import { Vendor } from '@/modules/Vendors/models/Vendor';
import { Bill } from '@/modules/Bills/models/Bill';
import { BillPayment } from '@/modules/BillPayments/models/BillPayment';
import { BillPaymentEntry } from '@/modules/BillPayments/models/BillPaymentEntry';
import { BillLandedCostEntry } from '@/modules/BillLandedCosts/models/BillLandedCostEntry';
import { BillLandedCost } from '@/modules/BillLandedCosts/models/BillLandedCost';
import { VendorCreditAppliedBill } from '@/modules/VendorCreditsApplyBills/models/VendorCreditAppliedBill';
import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice';
import { PaymentReceivedEntry } from '@/modules/PaymentReceived/models/PaymentReceivedEntry';
import { CreditNoteAppliedInvoice } from '@/modules/CreditNotesApplyInvoice/models/CreditNoteAppliedInvoice';
import { CreditNote } from '@/modules/CreditNotes/models/CreditNote';
import { PaymentLink } from '@/modules/PaymentLinks/models/PaymentLink';
import { SaleReceipt } from '@/modules/SaleReceipts/models/SaleReceipt';
import { ManualJournal } from '@/modules/ManualJournals/models/ManualJournal';
import { ManualJournalEntry } from '@/modules/ManualJournals/models/ManualJournalEntry';
import { RefundCreditNote } from '@/modules/CreditNoteRefunds/models/RefundCreditNote';
import { VendorCredit } from '@/modules/VendorCredit/models/VendorCredit';
import { RefundVendorCredit } from '@/modules/VendorCreditsRefund/models/RefundVendorCredit';
import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived';
import { Model } from 'objection';
import { ClsModule } from 'nestjs-cls';
import { TenantUser } from './models/TenantUser.model';
const models = [
Item,
Account,
ItemEntry,
AccountTransaction,
Expense,
ExpenseCategory,
ItemCategory,
TaxRateModel,
PdfTemplateModel,
Warehouse,
ItemWarehouseQuantity,
Branch,
SaleEstimate,
Customer,
Contact,
Document,
DocumentLink,
Vendor,
Bill,
BillPayment,
BillPaymentEntry,
BillLandedCost,
BillLandedCostEntry,
VendorCreditAppliedBill,
SaleInvoice,
CreditNoteAppliedInvoice,
CreditNote,
RefundCreditNote,
PaymentLink,
SaleReceipt,
ManualJournal,
ManualJournalEntry,
VendorCredit,
VendorCreditAppliedBill,
RefundVendorCredit,
PaymentReceived,
PaymentReceivedEntry,
TenantUser,
];
/**
* Decorator factory that registers a model with the tenancy system.
* @param model The model class to register
*/
export function RegisterTenancyModel(model: typeof Model) {
return ClsModule.forFeatureAsync({
provide: model.name,
inject: [TENANCY_DB_CONNECTION],
global: true,
useFactory: (tenantKnex: () => Knex) => () => {
return model.bindKnex(tenantKnex());
},
strict: true,
type: 'function',
});
}
// Register all models using the decorator
const modelProviders = models.map((model) => RegisterTenancyModel(model));
@Global()
@Module({
imports: [...modelProviders],
exports: [...modelProviders],
})
export class TenancyModelsModule {}

View File

@@ -0,0 +1,68 @@
import { Model } from 'objection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { Role } from '../../../Roles/models/Role.model';
export class TenantUser extends TenantBaseModel {
firstName!: string;
lastName!: string;
inviteAcceptedAt!: Date;
roleId!: number;
role!: Role;
/**
* Table name.
*/
static get tableName() {
return 'users';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['created_at', 'updated_at'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return ['isInviteAccepted', 'fullName'];
}
/**
* Detarmines whether the user ivnite is accept.
*/
get isInviteAccepted() {
return !!this.inviteAcceptedAt;
}
/**
* Full name attribute.
*/
get fullName() {
return `${this.firstName} ${this.lastName}`.trim();
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Role = require('models/Role');
return {
/**
* User belongs to user.
*/
role: {
relation: Model.BelongsToOneRelation,
modelClass: Role.default,
join: {
from: 'users.roleId',
to: 'roles.id',
},
},
};
}
}

View File

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