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,441 @@
/* eslint-disable global-require */
import { mixin, Model } from 'objection';
import { castArray } from 'lodash';
import DependencyGraph from '@/libs/dependency-graph';
import {
ACCOUNT_TYPES,
getAccountsSupportsMultiCurrency,
} from 'src/constants/accounts';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { SearchableModel } from '@/modules/Search/SearchableMdel';
import { CustomViewBaseModel } from '@/modules/CustomViews/CustomViewBaseModel';
import { ModelSettings } from '@/modules/Settings/ModelSettings';
import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils';
// import AccountSettings from './Account.Settings';
// import { DEFAULT_VIEWS } from '@/modules/Accounts/constants';
// import { buildFilterQuery, buildSortColumnQuery } from '@/lib/ViewRolesBuilder';
// import { flatToNestedArray } from 'utils';
// @ts-expect-error
export class Account extends mixin(TenantModel, [
ModelSettings,
CustomViewBaseModel,
SearchableModel,
]) {
accountType: string;
/**
* Table name.
*/
static get tableName() {
return 'accounts';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [
'accountTypeLabel',
'accountParentType',
'accountRootType',
'accountNormal',
'accountNormalFormatted',
'isBalanceSheetAccount',
'isPLSheet',
];
}
/**
* Account normal.
*/
get accountNormal() {
return AccountTypesUtils.getType(this.accountType, 'normal');
}
get accountNormalFormatted() {
const paris = {
credit: 'Credit',
debit: 'Debit',
};
return paris[this.accountNormal] || '';
}
/**
* Retrieve account type label.
*/
get accountTypeLabel() {
return AccountTypesUtils.getType(this.accountType, 'label');
}
/**
* Retrieve account parent type.
*/
get accountParentType() {
return AccountTypesUtils.getType(this.accountType, 'parentType');
}
/**
* Retrieve account root type.
*/
get accountRootType() {
return AccountTypesUtils.getType(this.accountType, 'rootType');
}
/**
* Retrieve whether the account is balance sheet account.
*/
get isBalanceSheetAccount() {
return this.isBalanceSheet();
}
/**
* Retrieve whether the account is profit/loss sheet account.
*/
get isPLSheet() {
return this.isProfitLossSheet();
}
/**
* Allows to mark model as resourceable to viewable and filterable.
*/
static get resourceable() {
return true;
}
/**
* Model modifiers.
*/
static get modifiers() {
const TABLE_NAME = Account.tableName;
return {
/**
* Inactive/Active mode.
*/
inactiveMode(query, active = false) {
query.where('accounts.active', !active);
},
filterAccounts(query, accountIds) {
if (accountIds.length > 0) {
query.whereIn(`${TABLE_NAME}.id`, accountIds);
}
},
filterAccountTypes(query, typesIds) {
if (typesIds.length > 0) {
query.whereIn('account_types.account_type_id', typesIds);
}
},
viewRolesBuilder(query, conditionals, expression) {
// buildFilterQuery(Account.tableName, conditionals, expression)(query);
},
sortColumnBuilder(query, columnKey, direction) {
// buildSortColumnQuery(Account.tableName, columnKey, direction)(query);
},
/**
* Filter by root type.
*/
filterByRootType(query, rootType) {
const filterTypes = ACCOUNT_TYPES.filter(
(accountType) => accountType.rootType === rootType,
).map((accountType) => accountType.key);
query.whereIn('account_type', filterTypes);
},
/**
* Filter by account normal
*/
filterByAccountNormal(query, accountNormal) {
const filterTypes = ACCOUNT_TYPES.filter(
(accountType) => accountType.normal === accountNormal,
).map((accountType) => accountType.key);
query.whereIn('account_type', filterTypes);
},
/**
* Finds account by the given slug.
* @param {*} query
* @param {*} slug
*/
findBySlug(query, slug) {
query.where('slug', slug).first();
},
/**
*
* @param {*} query
* @param {*} baseCyrrency
*/
preventMutateBaseCurrency(query) {
const accountsTypes = getAccountsSupportsMultiCurrency();
const accountsTypesKeys = accountsTypes.map((type) => type.key);
query
.whereIn('accountType', accountsTypesKeys)
.where('seededAt', null)
.first();
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
// const AccountTransaction = require('models/AccountTransaction');
// const Item = require('models/Item');
// const InventoryAdjustment = require('models/InventoryAdjustment');
// const ManualJournalEntry = require('models/ManualJournalEntry');
// const Expense = require('models/Expense');
// const ExpenseEntry = require('models/ExpenseCategory');
// const ItemEntry = require('models/ItemEntry');
// const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
// const PlaidItem = require('models/PlaidItem');
return {
// /**
// * Account model may has many transactions.
// */
// transactions: {
// relation: Model.HasManyRelation,
// modelClass: AccountTransaction.default,
// join: {
// from: 'accounts.id',
// to: 'accounts_transactions.accountId',
// },
// },
// /**
// *
// */
// itemsCostAccount: {
// relation: Model.HasManyRelation,
// modelClass: Item.default,
// join: {
// from: 'accounts.id',
// to: 'items.costAccountId',
// },
// },
// /**
// *
// */
// itemsSellAccount: {
// relation: Model.HasManyRelation,
// modelClass: Item.default,
// join: {
// from: 'accounts.id',
// to: 'items.sellAccountId',
// },
// },
// /**
// *
// */
// inventoryAdjustments: {
// relation: Model.HasManyRelation,
// modelClass: InventoryAdjustment.default,
// join: {
// from: 'accounts.id',
// to: 'inventory_adjustments.adjustmentAccountId',
// },
// },
// /**
// *
// */
// manualJournalEntries: {
// relation: Model.HasManyRelation,
// modelClass: ManualJournalEntry.default,
// join: {
// from: 'accounts.id',
// to: 'manual_journals_entries.accountId',
// },
// },
// /**
// *
// */
// expensePayments: {
// relation: Model.HasManyRelation,
// modelClass: Expense.default,
// join: {
// from: 'accounts.id',
// to: 'expenses_transactions.paymentAccountId',
// },
// },
// /**
// *
// */
// expenseEntries: {
// relation: Model.HasManyRelation,
// modelClass: ExpenseEntry.default,
// join: {
// from: 'accounts.id',
// to: 'expense_transaction_categories.expenseAccountId',
// },
// },
// /**
// *
// */
// entriesCostAccount: {
// relation: Model.HasManyRelation,
// modelClass: ItemEntry.default,
// join: {
// from: 'accounts.id',
// to: 'items_entries.costAccountId',
// },
// },
// /**
// *
// */
// entriesSellAccount: {
// relation: Model.HasManyRelation,
// modelClass: ItemEntry.default,
// join: {
// from: 'accounts.id',
// to: 'items_entries.sellAccountId',
// },
// },
// /**
// * Associated uncategorized transactions.
// */
// uncategorizedTransactions: {
// relation: Model.HasManyRelation,
// modelClass: UncategorizedTransaction.default,
// join: {
// from: 'accounts.id',
// to: 'uncategorized_cashflow_transactions.accountId',
// },
// filter: (query) => {
// query.where('categorized', false);
// },
// },
// /**
// * Account model may belongs to a Plaid item.
// */
// plaidItem: {
// relation: Model.BelongsToOneRelation,
// modelClass: PlaidItem.default,
// join: {
// from: 'accounts.plaidItemId',
// to: 'plaid_items.plaidItemId',
// },
// },
};
}
/**
* Detarmines whether the given type equals the account type.
* @param {string} accountType
* @return {boolean}
*/
isAccountType(accountType) {
const types = castArray(accountType);
return types.indexOf(this.accountType) !== -1;
}
/**
* Detarmines whether the given root type equals the account type.
* @param {string} rootType
* @return {boolean}
*/
isRootType(rootType) {
return AccountTypesUtils.isRootTypeEqualsKey(this.accountType, rootType);
}
/**
* Detarmine whether the given parent type equals the account type.
* @param {string} parentType
* @return {boolean}
*/
isParentType(parentType) {
return AccountTypesUtils.isParentTypeEqualsKey(
this.accountType,
parentType,
);
}
/**
* Detarmines whether the account is balance sheet account.
* @return {boolean}
*/
isBalanceSheet() {
return AccountTypesUtils.isTypeBalanceSheet(this.accountType);
}
/**
* Detarmines whether the account is profit/loss account.
* @return {boolean}
*/
isProfitLossSheet() {
return AccountTypesUtils.isTypePLSheet(this.accountType);
}
/**
* Detarmines whether the account is income statement account
* @return {boolean}
*/
isIncomeSheet() {
return this.isProfitLossSheet();
}
/**
* Converts flatten accounts list to nested array.
* @param {Array} accounts
* @param {Object} options
*/
static toNestedArray(accounts, options = { children: 'children' }) {
// return flatToNestedArray(accounts, {
// id: 'id',
// parentId: 'parentAccountId',
// });
}
/**
* Transformes the accounts list to depenedency graph structure.
* @param {IAccount[]} accounts
*/
static toDependencyGraph(accounts) {
return DependencyGraph.fromArray(accounts, {
itemId: 'id',
parentItemId: 'parentAccountId',
});
}
/**
* Model settings.
*/
// static get meta() {
// return AccountSettings;
// }
/**
* Retrieve the default custom views, roles and columns.
*/
// static get defaultViews() {
// return DEFAULT_VIEWS;
// }
/**
* Model search roles.
*/
static get searchRoles() {
return [
{ condition: 'or', fieldKey: 'name', comparator: 'contains' },
{ condition: 'or', fieldKey: 'code', comparator: 'like' },
];
}
/**
* Prevents mutate base currency since the model is not empty.
*/
static get preventMutateBaseCurrency() {
return true;
}
}

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './App.controller';
import { AppService } from './App.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './App.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -0,0 +1,115 @@
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { join } from 'path';
import {
AcceptLanguageResolver,
CookieResolver,
HeaderResolver,
I18nModule,
QueryResolver,
} from 'nestjs-i18n';
import { BullModule } from '@nestjs/bullmq';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ClsModule } from 'nestjs-cls';
import { AppController } from './App.controller';
import { AppService } from './App.service';
import { ItemsModule } from '../Items/items.module';
import { config } from '../../common/config';
import { SystemDatabaseModule } from '../System/SystemDB/SystemDB.module';
import { SystemModelsModule } from '../System/SystemModels/SystemModels.module';
import { JwtStrategy } from '../Auth/Jwt.strategy';
import { jwtConstants } from '../Auth/Auth.constants';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { TenancyModelsModule } from '../Tenancy/TenancyModels/Tenancy.module';
import { LoggerMiddleware } from '@/middleware/logger.middleware';
import { ExcludeNullInterceptor } from '@/interceptors/ExcludeNull.interceptor';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { JwtAuthGuard } from '../Auth/Jwt.guard';
import { UserIpInterceptor } from '@/interceptors/user-ip.interceptor';
import { TenancyGlobalMiddleware } from '../Tenancy/TenancyGlobal.middleware';
@Module({
imports: [
ConfigModule.forRoot({
envFilePath: '.env',
load: config,
isGlobal: true,
}),
SystemDatabaseModule,
SystemModelsModule,
EventEmitterModule.forRoot(),
I18nModule.forRootAsync({
useFactory: () => ({
fallbackLanguage: 'en',
loaderOptions: {
path: join(__dirname, '/../../i18n/'),
watch: true,
},
}),
resolvers: [
new QueryResolver(),
new HeaderResolver(),
new CookieResolver(),
AcceptLanguageResolver,
],
}),
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
connection: {
host: configService.get('QUEUE_HOST'),
port: configService.get('QUEUE_PORT'),
},
}),
inject: [ConfigService],
}),
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
setup: (cls, req: Request, res: Response) => {
cls.set('organizationId', req.headers['organization-id']);
cls.set('userId', 1);
},
},
}),
TenancyDatabaseModule,
TenancyModelsModule,
ItemsModule,
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: UserIpInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: ExcludeNullInterceptor,
},
AppService,
JwtStrategy,
],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
consumer
.apply(TenancyGlobalMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AppService {
// configService: ConfigService;
constructor(
private configService: ConfigService,
private jwtService: JwtService,
) {}
getHello(): string {
console.log(this.configService.get('DATABASE_PORT'));
const payload = {};
const accessToken = this.jwtService.sign(payload);
console.log(accessToken);
return accessToken;
}
}

View File

@@ -0,0 +1,4 @@
export const jwtConstants = {
secret:
'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,32 @@
import {
ExecutionContext,
Injectable,
Scope,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ClsService } from 'nestjs-cls';
export const IS_PUBLIC_KEY = 'isPublic';
export const PublicRoute = () => SetMetadata(IS_PUBLIC_KEY, true);
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private reflector: Reflector,
private readonly cls: ClsService,
) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,19 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './Auth.constants';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}

View File

@@ -0,0 +1,22 @@
import { Model as ObjectionModel } from 'objection';
export const CustomViewBaseModel = (Model) =>
class extends Model {
/**
* Retrieve the default custom views, roles and columns.
*/
static get defaultViews() {
return [];
}
/**
* Retrieve the default view by the given slug.
*/
static getDefaultViewBySlug(viewSlug) {
return this.defaultViews.find((view) => view.slug === viewSlug) || null;
}
static getDefaultViews() {
return this.defaultViews;
}
};

View File

@@ -0,0 +1,120 @@
import { Knex } from 'knex';
import { defaultTo } from 'lodash';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { IItemDTO, IItemEventCreatedPayload } from '@/interfaces/Item';
import { events } from '@/common/events/events';
import { ItemsValidators } from './ItemValidator.service';
import { Item } from './models/Item';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
@Injectable({ scope: Scope.REQUEST })
export class CreateItemService {
/**
* Constructor for the CreateItemService class.
* @param {EventEmitter2} eventEmitter - Event emitter for publishing item creation events.
* @param {UnitOfWork} uow - Unit of Work for tenant database transactions.
* @param {ItemsValidators} validators - Service for validating item data.
* @param {typeof Item} itemModel - The Item model class for database operations.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validators: ItemsValidators,
@Inject(Item.name)
private readonly itemModel: typeof Item,
) {}
/**
* Authorize the creating item.
* @param {number} tenantId
* @param {IItemDTO} itemDTO
*/
async authorize(itemDTO: IItemDTO) {
// Validate whether the given item name already exists on the storage.
await this.validators.validateItemNameUniquiness(itemDTO.name);
if (itemDTO.categoryId) {
await this.validators.validateItemCategoryExistance(itemDTO.categoryId);
}
if (itemDTO.sellAccountId) {
await this.validators.validateItemSellAccountExistance(
itemDTO.sellAccountId,
);
}
// Validate the income account id existance if the item is sellable.
this.validators.validateIncomeAccountExistance(
itemDTO.sellable,
itemDTO.sellAccountId,
);
if (itemDTO.costAccountId) {
await this.validators.validateItemCostAccountExistance(
itemDTO.costAccountId,
);
}
// Validate the cost account id existance if the item is purchasable.
this.validators.validateCostAccountExistance(
itemDTO.purchasable,
itemDTO.costAccountId,
);
if (itemDTO.inventoryAccountId) {
await this.validators.validateItemInventoryAccountExistance(
itemDTO.inventoryAccountId,
);
}
if (itemDTO.purchaseTaxRateId) {
await this.validators.validatePurchaseTaxRateExistance(
itemDTO.purchaseTaxRateId,
);
}
if (itemDTO.sellTaxRateId) {
await this.validators.validateSellTaxRateExistance(itemDTO.sellTaxRateId);
}
}
/**
* Transforms the item DTO to model.
* @param {IItemDTO} itemDTO - Item DTO.
* @return {IItem}
*/
private transformNewItemDTOToModel(itemDTO: IItemDTO) {
return {
...itemDTO,
active: defaultTo(itemDTO.active, 1),
quantityOnHand: itemDTO.type === 'inventory' ? 0 : null,
};
}
/**
* Creates a new item.
* @param {IItemDTO} itemDTO
* @return {Promise<IItem>}
*/
public async createItem(
itemDTO: IItemDTO,
trx?: Knex.Transaction,
): Promise<Item> {
// Authorize the item before creating.
await this.authorize(itemDTO);
// Creates a new item with associated transactions under unit-of-work envirement.
return this.uow.withTransaction<Item>(async (trx: Knex.Transaction) => {
const itemInsert = this.transformNewItemDTOToModel(itemDTO);
// Inserts a new item and fetch the created item.
const item = await this.itemModel.query(trx).insertAndFetch({
...itemInsert,
});
// Triggers `onItemCreated` event.
await this.eventEmitter.emitAsync(events.item.onCreated, {
item,
itemId: item.id,
trx,
} as IItemEventCreatedPayload);
return item;
}, trx);
}
}

View File

@@ -0,0 +1,67 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
IItemEventDeletedPayload,
IItemEventDeletingPayload,
} from 'src/interfaces/Item';
import { events } from 'src/common/events/events';
import { Item } from './models/Item';
import { ERRORS } from './Items.constants';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
@Injectable()
export class DeleteItemService {
/**
* Constructor for the class.
* @param {EventEmitter2} eventEmitter - Event emitter for publishing item deletion events.
* @param {UnitOfWork} uow - Unit of Work for tenant database transactions.
* @param {typeof Item} itemModel - The Item model class for database operations.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
@Inject(Item.name)
private readonly itemModel: typeof Item,
) {}
/**
* Delete the given item from the storage.
* @param {number} itemId - Item id.
* @return {Promise<void>}
*/
public async deleteItem(
itemId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the given item or throw not found service error.
const oldItem = await this.itemModel
.query()
.findById(itemId)
.throwIfNotFound()
// @ts-expect-error
.queryAndThrowIfHasRelations({
type: ERRORS.ITEM_HAS_ASSOCIATED_TRANSACTIONS,
});
// Delete item in unit of work.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onItemDeleting` event.
await this.eventEmitter.emitAsync(events.item.onDeleting, {
trx,
oldItem,
} as IItemEventDeletingPayload);
// Deletes the item.
await this.itemModel.query(trx).findById(itemId).delete();
// Triggers `onItemDeleted` event.
await this.eventEmitter.emitAsync(events.item.onDeleted, {
itemId,
oldItem,
trx,
} as IItemEventDeletedPayload);
}, trx);
}
}

View File

@@ -0,0 +1,143 @@
import { Knex } from 'knex';
import { Injectable, Inject } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { IItemDTO, IItemEventEditedPayload } from 'src/interfaces/Item';
import { events } from 'src/common/events/events';
import { ItemsValidators } from './ItemValidator.service';
import { Item } from './models/Item';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
@Injectable()
export class EditItemService {
/**
* Constructor for the class.
* @param {EventEmitter2} eventEmitter - Event emitter for publishing item edit events.
* @param {UnitOfWork} uow - Unit of Work for tenant database transactions.
* @param {ItemsValidators} validators - Service for validating item data.
* @param {typeof Item} itemModel - The Item model class for database operations.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validators: ItemsValidators,
@Inject(Item.name)
private readonly itemModel: typeof Item,
) {}
/**
* Authorize the editing item.
* @param {IItemDTO} itemDTO
* @param {Item} oldItem
*/
async authorize(itemDTO: IItemDTO, oldItem: Item) {
// Validate edit item type from inventory type.
this.validators.validateEditItemFromInventory(itemDTO, oldItem);
// Validate edit item type to inventory type.
await this.validators.validateEditItemTypeToInventory(oldItem, itemDTO);
// Validate whether the given item name already exists on the storage.
await this.validators.validateItemNameUniquiness(itemDTO.name, oldItem.id);
if (itemDTO.categoryId) {
await this.validators.validateItemCategoryExistance(itemDTO.categoryId);
}
if (itemDTO.sellAccountId) {
await this.validators.validateItemSellAccountExistance(
itemDTO.sellAccountId,
);
}
// Validate the income account id existance if the item is sellable.
this.validators.validateIncomeAccountExistance(
itemDTO.sellable,
itemDTO.sellAccountId,
);
if (itemDTO.costAccountId) {
await this.validators.validateItemCostAccountExistance(
itemDTO.costAccountId,
);
}
// Validate the cost account id existance if the item is purchasable.
this.validators.validateCostAccountExistance(
itemDTO.purchasable,
itemDTO.costAccountId,
);
if (itemDTO.inventoryAccountId) {
await this.validators.validateItemInventoryAccountExistance(
itemDTO.inventoryAccountId,
);
}
if (itemDTO.purchaseTaxRateId) {
await this.validators.validatePurchaseTaxRateExistance(
itemDTO.purchaseTaxRateId,
);
}
if (itemDTO.sellTaxRateId) {
await this.validators.validateSellTaxRateExistance(itemDTO.sellTaxRateId);
}
}
/**
* Transforms the edit item DTO to model.
* @param {IItemDTO} itemDTO - Item DTO.
* @param {Item} oldItem - Old item.
* @return {Partial<Item>}
*/
private transformEditItemDTOToModel(
itemDTO: IItemDTO,
oldItem: Item,
): Partial<Item> {
return {
...itemDTO,
...(itemDTO.type === 'inventory' && oldItem.type !== 'inventory'
? {
quantityOnHand: 0,
}
: {}),
};
}
/**
* Edits the item metadata.
* @param {number} itemId
* @param {IItemDTO} itemDTO
*/
public async editItem(
itemId: number,
itemDTO: IItemDTO,
trx?: Knex.Transaction,
): Promise<Item> {
// Validates the given item existance on the storage.
const oldItem = await this.itemModel
.query()
.findById(itemId)
.throwIfNotFound();
// Authorize before editing item.
await this.authorize(itemDTO, oldItem);
// Transform the edit item DTO to model.
const itemModel = this.transformEditItemDTOToModel(itemDTO, oldItem);
// Edits the item with associated transactions under unit-of-work environment.
return this.uow.withTransaction<Item>(async (trx: Knex.Transaction) => {
// Updates the item on the storage and fetches the updated one.
const newItem = await this.itemModel
.query(trx)
.patchAndFetchById(itemId, itemModel);
// Edit event payload.
const eventPayload: IItemEventEditedPayload = {
item: newItem,
oldItem,
itemId: newItem.id,
trx,
};
// Triggers `onItemEdited` event.
await this.eventEmitter.emitAsync(events.item.onEdited, eventPayload);
return newItem;
}, trx);
}
}

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Delete,
Param,
Post,
UsePipes,
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ZodValidationPipe } from 'src/common/pipes/ZodValidation.pipe';
import { createItemSchema } from './Item.schema';
import { CreateItemService } from './CreateItem.service';
import { Item } from './models/Item';
import { DeleteItemService } from './DeleteItem.service';
import { TenantController } from '../Tenancy/Tenant.controller';
import { SubscriptionGuard } from '../Subscription/interceptors/Subscription.guard';
import { ClsService } from 'nestjs-cls';
import { PublicRoute } from '../Auth/Jwt.guard';
@Controller('/items')
@UseGuards(SubscriptionGuard)
@PublicRoute()
export class ItemsController extends TenantController {
constructor(
private readonly createItemService: CreateItemService,
private readonly deleteItemService: DeleteItemService,
private readonly cls: ClsService,
) {
super();
}
@Post()
@UsePipes(new ZodValidationPipe(createItemSchema))
async createItem(@Body() createItemDto: any): Promise<Item> {
return this.createItemService.createItem(createItemDto);
}
@Delete(':id')
async deleteItem(@Param('id') id: string): Promise<void> {
const itemId = parseInt(id, 10);
return this.deleteItemService.deleteItem(itemId);
}
}

View File

@@ -0,0 +1,111 @@
import { DATATYPES_LENGTH } from 'src/constants/data-types';
import z from 'zod';
export const createItemSchema = z
.object({
name: z.string().max(DATATYPES_LENGTH.STRING),
type: z.enum(['service', 'non-inventory', 'inventory']),
code: z.string().max(DATATYPES_LENGTH.STRING).nullable().optional(),
purchasable: z.boolean().optional(),
cost_price: z
.number()
.min(0)
.max(DATATYPES_LENGTH.DECIMAL_13_3)
.nullable()
.optional(),
cost_account_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
sellable: z.boolean().optional(),
sell_price: z
.number()
.min(0)
.max(DATATYPES_LENGTH.DECIMAL_13_3)
.nullable()
.optional(),
sell_account_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
inventory_account_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
sell_description: z
.string()
.max(DATATYPES_LENGTH.TEXT)
.nullable()
.optional(),
purchase_description: z
.string()
.max(DATATYPES_LENGTH.TEXT)
.nullable()
.optional(),
sell_tax_rate_id: z.number().int().nullable().optional(),
purchase_tax_rate_id: z.number().int().nullable().optional(),
category_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
note: z.string().max(DATATYPES_LENGTH.TEXT).optional(),
active: z.boolean().optional(),
media_ids: z.array(z.number().int()).optional(),
})
.refine(
(data) => {
if (data.purchasable) {
return (
data.cost_price !== undefined && data.cost_account_id !== undefined
);
}
return true;
},
{
message:
'Cost price and cost account ID are required when item is purchasable',
path: ['cost_price', 'cost_account_id'],
},
)
.refine(
(data) => {
if (data.sellable) {
return (
data.sell_price !== undefined && data.sell_account_id !== undefined
);
}
return true;
},
{
message:
'Sell price and sell account ID are required when item is sellable',
path: ['sell_price', 'sell_account_id'],
},
)
.refine(
(data) => {
if (data.type === 'inventory') {
return data.inventory_account_id !== undefined;
}
return true;
},
{
message: 'Inventory account ID is required for inventory items',
path: ['inventory_account_id'],
},
);
export type createItemDTO = z.infer<typeof createItemSchema>;

View File

@@ -0,0 +1,285 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ACCOUNT_PARENT_TYPE,
ACCOUNT_ROOT_TYPE,
ACCOUNT_TYPE,
} from 'src/constants/accounts';
import { ServiceError } from './ServiceError';
import { IItem, IItemDTO } from 'src/interfaces/Item';
import { ERRORS } from './Items.constants';
import { Item } from './models/Item';
import { Account } from '../Accounts/models/Account';
@Injectable()
export class ItemsValidators {
constructor(
@Inject(Item.name) private itemModel: typeof Item,
@Inject(Account.name) private accountModel: typeof Account,
@Inject(Item.name) private taxRateModel: typeof Item,
@Inject(Item.name) private itemEntryModel: typeof Item,
@Inject(Item.name) private itemCategoryModel: typeof Item,
@Inject(Item.name) private accountTransactionModel: typeof Item,
@Inject(Item.name) private inventoryAdjustmentEntryModel: typeof Item,
) {}
/**
* Validate wether the given item name already exists on the storage.
* @param {string} itemName
* @param {number} notItemId
* @return {Promise<void>}
*/
public async validateItemNameUniquiness(
itemName: string,
notItemId?: number,
): Promise<void> {
const foundItems = await this.itemModel.query().onBuild((builder: any) => {
builder.where('name', itemName);
if (notItemId) {
builder.whereNot('id', notItemId);
}
});
if (foundItems.length > 0) {
throw new ServiceError(
ERRORS.ITEM_NAME_EXISTS,
'The item name is already exist.',
);
}
}
/**
* Validate item COGS account existance and type.
* @param {number} costAccountId
* @return {Promise<void>}
*/
public async validateItemCostAccountExistance(
costAccountId: number,
): Promise<void> {
const foundAccount = await this.accountModel
.query()
.findById(costAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_FOUMD);
// Detarmines the cost of goods sold account.
} else if (!foundAccount.isParentType(ACCOUNT_PARENT_TYPE.EXPENSE)) {
throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_COGS);
}
}
/**
* Validate item sell account existance and type.
* @param {number} sellAccountId - Sell account id.
*/
public async validateItemSellAccountExistance(sellAccountId: number) {
const foundAccount = await this.accountModel
.query()
.findById(sellAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_FOUND);
} else if (!foundAccount.isParentType(ACCOUNT_ROOT_TYPE.INCOME)) {
throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_INCOME);
}
}
/**
* Validates income account existance.
* @param {number|null} sellable - Detarmines if the item sellable.
* @param {number|null} incomeAccountId - Income account id.
* @throws {ServiceError(ERRORS.INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM)}
*/
public validateIncomeAccountExistance(
sellable?: boolean,
incomeAccountId?: number,
) {
if (sellable && !incomeAccountId) {
throw new ServiceError(
ERRORS.INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM,
'Income account is require with sellable item.',
);
}
}
/**
* Validates the cost account existance.
* @param {boolean|null} purchasable - Detarmines if the item purchasble.
* @param {number|null} costAccountId - Cost account id.
* @throws {ServiceError(ERRORS.COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM)}
*/
public validateCostAccountExistance(
purchasable: boolean,
costAccountId?: number,
) {
if (purchasable && !costAccountId) {
throw new ServiceError(
ERRORS.COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM,
'The cost account is required with purchasable item.',
);
}
}
/**
* Validate item inventory account existance and type.
* @param {number} inventoryAccountId
*/
public async validateItemInventoryAccountExistance(
inventoryAccountId: number,
) {
const foundAccount = await this.accountModel
.query()
.findById(inventoryAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_FOUND);
} else if (!foundAccount.isAccountType(ACCOUNT_TYPE.INVENTORY)) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_INVENTORY);
}
}
/**
* Validate item category existance.
* @param {number} itemCategoryId
*/
public async validateItemCategoryExistance(itemCategoryId: number) {
const foundCategory = await this.itemCategoryModel
.query()
.findById(itemCategoryId);
if (!foundCategory) {
throw new ServiceError(ERRORS.ITEM_CATEOGRY_NOT_FOUND);
}
}
/**
* Validates the given item or items have no associated invoices or bills.
* @param {number|number[]} itemId - Item id.
* @throws {ServiceError}
*/
public async validateHasNoInvoicesOrBills(itemId: number[] | number) {
const ids = Array.isArray(itemId) ? itemId : [itemId];
const foundItemEntries = await this.itemEntryModel
.query()
.whereIn('item_id', ids);
if (foundItemEntries.length > 0) {
throw new ServiceError(
ids.length > 1
? ERRORS.ITEMS_HAVE_ASSOCIATED_TRANSACTIONS
: ERRORS.ITEM_HAS_ASSOCIATED_TRANSACTINS,
);
}
}
/**
* Validates the given item has no associated inventory adjustment transactions.
* @param {number} itemId -
*/
public async validateHasNoInventoryAdjustments(
itemId: number[] | number,
): Promise<void> {
const itemsIds = Array.isArray(itemId) ? itemId : [itemId];
const inventoryAdjEntries = await this.inventoryAdjustmentEntryModel
.query()
.whereIn('item_id', itemsIds);
if (inventoryAdjEntries.length > 0) {
throw new ServiceError(ERRORS.ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT);
}
}
/**
* Validates edit item type from service/non-inventory to inventory.
* Should item has no any relations with accounts transactions.
* @param {number} itemId - Item id.
*/
public async validateEditItemTypeToInventory(
oldItem: Item,
newItemDTO: IItemDTO,
) {
// We have no problem in case the item type not modified.
if (newItemDTO.type === oldItem.type || oldItem.type === 'inventory') {
return;
}
// Retrieve all transactions that associated to the given item id.
const itemTransactionsCount = await this.accountTransactionModel
.query()
.where('item_id', oldItem.id)
.count('item_id', { as: 'transactions' })
.first();
// @ts-ignore
if (itemTransactionsCount.transactions > 0) {
throw new ServiceError(
ERRORS.TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS,
);
}
}
/**
* Validate the item inventory account whether modified and item
* has associated inventory transactions.
* @param {Item} oldItem
* @param {IItemDTO} newItemDTO
* @returns
*/
async validateItemInvnetoryAccountModified(
oldItem: Item,
newItemDTO: IItemDTO,
) {
if (
newItemDTO.type !== 'inventory' ||
oldItem.inventoryAccountId === newItemDTO.inventoryAccountId
) {
return;
}
// Inventory transactions associated to the given item id.
const transactions = await this.accountTransactionModel.query().where({
itemId: oldItem.id,
});
// Throw the service error in case item has associated inventory transactions.
if (transactions.length > 0) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_CANNOT_MODIFIED);
}
}
/**
* Validate edit item type from inventory to another type that not allowed.
* @param {IItemDTO} itemDTO - Item DTO.
* @param {IItem} oldItem - Old item.
*/
public validateEditItemFromInventory(itemDTO: IItemDTO, oldItem: Item) {
if (
itemDTO.type &&
oldItem.type === 'inventory' &&
itemDTO.type !== oldItem.type
) {
throw new ServiceError(ERRORS.ITEM_CANNOT_CHANGE_INVENTORY_TYPE);
}
}
/**
* Validate the purchase tax rate id existance.
* @param {number} taxRateId -
*/
public async validatePurchaseTaxRateExistance(taxRateId: number) {
const foundTaxRate = await this.taxRateModel.query().findById(taxRateId);
if (!foundTaxRate) {
throw new ServiceError(ERRORS.PURCHASE_TAX_RATE_NOT_FOUND);
}
}
/**
* Validate the sell tax rate id existance.
* @param {number} taxRateId
*/
public async validateSellTaxRateExistance(taxRateId: number) {
const foundTaxRate = await this.taxRateModel.query().findById(taxRateId);
if (!foundTaxRate) {
throw new ServiceError(ERRORS.SELL_TAX_RATE_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,141 @@
export const ERRORS = {
NOT_FOUND: 'NOT_FOUND',
ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND',
ITEM_NAME_EXISTS: 'ITEM_NAME_EXISTS',
ITEM_CATEOGRY_NOT_FOUND: 'ITEM_CATEOGRY_NOT_FOUND',
COST_ACCOUNT_NOT_COGS: 'COST_ACCOUNT_NOT_COGS',
COST_ACCOUNT_NOT_FOUMD: 'COST_ACCOUNT_NOT_FOUMD',
SELL_ACCOUNT_NOT_FOUND: 'SELL_ACCOUNT_NOT_FOUND',
SELL_ACCOUNT_NOT_INCOME: 'SELL_ACCOUNT_NOT_INCOME',
INVENTORY_ACCOUNT_NOT_FOUND: 'INVENTORY_ACCOUNT_NOT_FOUND',
INVENTORY_ACCOUNT_NOT_INVENTORY: 'INVENTORY_ACCOUNT_NOT_INVENTORY',
ITEMS_HAVE_ASSOCIATED_TRANSACTIONS: 'ITEMS_HAVE_ASSOCIATED_TRANSACTIONS',
ITEM_HAS_ASSOCIATED_TRANSACTINS: 'ITEM_HAS_ASSOCIATED_TRANSACTINS',
ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT:
'ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT',
ITEM_CANNOT_CHANGE_INVENTORY_TYPE: 'ITEM_CANNOT_CHANGE_INVENTORY_TYPE',
TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS:
'TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS',
INVENTORY_ACCOUNT_CANNOT_MODIFIED: 'INVENTORY_ACCOUNT_CANNOT_MODIFIED',
ITEM_HAS_ASSOCIATED_TRANSACTIONS: 'ITEM_HAS_ASSOCIATED_TRANSACTIONS',
PURCHASE_TAX_RATE_NOT_FOUND: 'PURCHASE_TAX_RATE_NOT_FOUND',
SELL_TAX_RATE_NOT_FOUND: 'SELL_TAX_RATE_NOT_FOUND',
INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM:
'INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM',
COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM:
'COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM',
};
export const DEFAULT_VIEW_COLUMNS = [];
export const DEFAULT_VIEWS = [
{
name: 'Services',
slug: 'services',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'type', comparator: 'equals', value: 'service' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Inventory',
slug: 'inventory',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'type', comparator: 'equals', value: 'inventory' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Non Inventory',
slug: 'non-inventory',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'type',
comparator: 'equals',
value: 'non-inventory',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
];
export const ItemsSampleData = [
{
'Item Type': 'Inventory',
'Item Name': 'Hettinger, Schumm and Bartoletti',
'Item Code': '1000',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'At dolor est non tempore et quisquam.',
Active: 'TRUE',
},
{
'Item Type': 'Inventory',
'Item Name': 'Schmitt Group',
'Item Code': '1001',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'Id perspiciatis at adipisci minus accusamus dolor iure dolore.',
Active: 'TRUE',
},
{
'Item Type': 'Inventory',
'Item Name': 'Marks - Carroll',
'Item Code': '1002',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'Odio odio minus similique.',
Active: 'TRUE',
},
{
'Item Type': 'Inventory',
'Item Name': 'VonRueden, Ruecker and Hettinger',
'Item Code': '1003',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'Quibusdam dolores illo.',
Active: 'TRUE',
},
];

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ItemsController } from './Item.controller';
import { CreateItemService } from './CreateItem.service';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { ItemsValidators } from './ItemValidator.service';
import { DeleteItemService } from './DeleteItem.service';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
@Module({
imports: [TenancyDatabaseModule],
controllers: [ItemsController],
providers: [
ItemsValidators,
CreateItemService,
DeleteItemService,
TenancyContext,
],
})
export class ItemsModule {}

View File

@@ -0,0 +1,13 @@
export class ServiceError {
errorType: string;
message: string;
payload: any;
constructor(errorType: string, message?: string, payload?: any) {
this.errorType = errorType;
this.message = message || null;
this.payload = payload;
}
}

View File

@@ -0,0 +1,4 @@
export class ItemCreatedEvent {
name: string;
description: string;
}

View File

@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ItemCreatedEvent } from '../events/ItemCreated.event';
@Injectable()
export class ItemCreatedListener {
@OnEvent('order.created')
handleItemCreatedEvent(event: ItemCreatedEvent) {
// handle and process "OrderCreatedEvent" event
console.log(event);
}
}

View File

@@ -0,0 +1,26 @@
import * as F from 'fp-ts/function';
import * as R from 'ramda';
import { SearchableModel } from '@/modules/Search/SearchableMdel';
import { TenantModel } from '@/modules/System/models/TenantModel';
const Extend = R.compose(SearchableModel)(TenantModel);
export class Item extends Extend {
public readonly quantityOnHand: number;
public readonly name: string;
public readonly active: boolean;
public readonly type: string;
public readonly code: string;
public readonly sellable: boolean;
public readonly purchasable: boolean;
public readonly costPrice: number;
public readonly sellPrice: number;
public readonly currencyCode: string;
public readonly costAccountId: number;
public readonly inventoryAccountId: number;
public readonly categoryId: number;
static get tableName() {
return 'items';
}
}

View File

@@ -0,0 +1,22 @@
import * as O from 'objection';
import { IModelMeta, } from '@/interfaces/Model';
export const SearchableModel: O.Plugin = (Model) =>
// @ts-ignore
class extends Model {
additionalProperty: string;
/**
* Searchable model.
*/
static get searchable(): IModelMeta {
throw true;
}
/**
* Search roles.
*/
// static get searchRoles(): ISearchRole[] {
// return [];
// }
};

View File

@@ -0,0 +1,77 @@
import { get } from 'lodash';
import {
IModelMeta,
IModelMetaField,
IModelMetaDefaultSort,
} from '@/interfaces/Model';
const defaultModelMeta = {
fields: {},
fields2: {},
};
export const ModelSettings = (Model) =>
class extends Model {
/**
*
* @returns {IModelMeta}
*/
static get meta(): IModelMeta {
throw new Error('');
}
/**
* Parsed meta merged with default emta.
* @returns {IModelMeta}
*/
static get parsedMeta(): IModelMeta {
return {
...defaultModelMeta,
...this.meta,
};
}
/**
* Retrieve specific model field meta of the given field key.
* @param {string} key
* @returns {IModelMetaField}
*/
public static getField(key: string, attribute?: string): IModelMetaField {
const field = get(this.meta.fields, key);
return attribute ? get(field, attribute) : field;
}
/**
* Retrieves the specific model meta.
* @param {string} key
* @returns
*/
public static getMeta(key?: string) {
return key ? get(this.parsedMeta, key) : this.parsedMeta;
}
/**
* Retrieve the model meta fields.
* @return {{ [key: string]: IModelMetaField }}
*/
public static get fields(): { [key: string]: IModelMetaField } {
return this.getMeta('fields');
}
/**
* Retrieve the model default sort settings.
* @return {IModelMetaDefaultSort}
*/
public static get defaultSort(): IModelMetaDefaultSort {
return this.getMeta('defaultSort');
}
/**
* Retrieve the default filter field key.
* @return {string}
*/
public static get defaultFilterField(): string {
return this.getMeta('defaultFilterField');
}
};

View File

@@ -0,0 +1,49 @@
import moment, { unitOfTime } from 'moment';
export class SubscriptionPeriod {
private start: Date;
private end: Date;
private interval: string;
private count: number;
/**
* Constructor method.
* @param {string} interval -
* @param {number} count -
* @param {Date} start -
*/
constructor(
interval: unitOfTime.DurationConstructor = 'month',
count: number,
start?: Date
) {
this.interval = interval;
this.count = count;
this.start = start;
if (!start) {
this.start = moment().toDate();
}
if (count === Infinity) {
this.end = null;
} else {
this.end = moment(start).add(count, interval).toDate();
}
}
getStartDate() {
return this.start;
}
getEndDate() {
return this.end;
}
getInterval() {
return this.interval;
}
getIntervalCount() {
return this.count;
}
}

View File

@@ -0,0 +1,46 @@
import {
Injectable,
CanActivate,
ExecutionContext,
Inject,
UnauthorizedException,
} from '@nestjs/common';
import { PlanSubscription } from '../models/PlanSubscription';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@Injectable()
export class SubscriptionGuard implements CanActivate {
constructor(
@Inject(PlanSubscription.name)
private readonly planSubscriptionModel: typeof PlanSubscription,
private readonly tenancyContext: TenancyContext,
) {}
/**
* Validates the tenant's subscription is exists and not inactive
* @param {ExecutionContext} context
* @param {string} subscriptionSlug
* @returns {Promise<boolean>}
*/
async canActivate(
context: ExecutionContext,
subscriptionSlug: string = 'main', // Default value
): Promise<boolean> {
const tenant = await this.tenancyContext.getTenant();
const subscription = await this.planSubscriptionModel
.query()
.findOne('slug', subscriptionSlug)
.where('tenant_id', tenant.id);
if (!subscription) {
throw new UnauthorizedException('Tenant has no subscription.');
}
const isSubscriptionInactive = subscription.inactive();
if (isSubscriptionInactive) {
throw new UnauthorizedException('Organization subscription is inactive.');
}
return true;
}
}

View File

@@ -0,0 +1,246 @@
import { Model, mixin } from 'objection';
import moment from 'moment';
import { SubscriptionPeriod } from '../SubscriptionPeriod';
import { SystemModel } from '@/modules/System/models/SystemModel';
import { SubscriptionPaymentStatus } from '@/interfaces/SubscriptionPlan';
export class PlanSubscription extends mixin(SystemModel) {
public readonly lemonSubscriptionId: number;
public readonly endsAt: Date;
public readonly startsAt: Date;
public readonly canceledAt: Date;
public readonly trialEndsAt: Date;
public readonly paymentStatus: SubscriptionPaymentStatus;
/**
* Table name.
*/
static get tableName() {
return 'subscription_plan_subscriptions';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Defined virtual attributes.
*/
static get virtualAttributes() {
return [
'active',
'inactive',
'ended',
'canceled',
'onTrial',
'status',
'isPaymentFailed',
'isPaymentSucceed',
];
}
/**
* Modifiers queries.
*/
static get modifiers() {
return {
activeSubscriptions(builder) {
const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const now = moment().format(dateFormat);
builder.where('ends_at', '>', now);
builder.where('trial_ends_at', '>', now);
},
inactiveSubscriptions(builder) {
builder.modify('endedTrial');
builder.modify('endedPeriod');
},
subscriptionBySlug(builder, subscriptionSlug) {
builder.where('slug', subscriptionSlug);
},
endedTrial(builder) {
const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const endDate = moment().format(dateFormat);
builder.where('ends_at', '<=', endDate);
},
endedPeriod(builder) {
const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const endDate = moment().format(dateFormat);
builder.where('trial_ends_at', '<=', endDate);
},
/**
* Filter the failed payment.
* @param builder
*/
failedPayment(builder) {
builder.where('payment_status', SubscriptionPaymentStatus.Failed);
},
/**
* Filter the succeed payment.
* @param builder
*/
succeedPayment(builder) {
builder.where('payment_status', SubscriptionPaymentStatus.Succeed);
},
};
}
/**
* Relations mappings.
*/
static get relationMappings() {
const Tenant = require('system/models/Tenant');
const Plan = require('system/models/Subscriptions/Plan');
return {
/**
* Plan subscription belongs to tenant.
*/
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: Tenant.default,
join: {
from: 'subscription_plan_subscriptions.tenantId',
to: 'tenants.id',
},
},
/**
* Plan description belongs to plan.
*/
plan: {
relation: Model.BelongsToOneRelation,
modelClass: Plan.default,
join: {
from: 'subscription_plan_subscriptions.planId',
to: 'subscription_plans.id',
},
},
};
}
/**
* Check if the subscription is active.
* Crtiria should be active:
* - During the trial period should NOT be canceled.
* - Out of trial period should NOT be ended.
* @return {Boolean}
*/
public active() {
return this.onTrial() ? !this.canceled() : !this.ended();
}
/**
* Check if the subscription is inactive.
* @return {Boolean}
*/
public inactive() {
return !this.active();
}
/**
* Check if paid subscription period has ended.
* @return {Boolean}
*/
public ended() {
return this.endsAt ? moment().isAfter(this.endsAt) : false;
}
/**
* Check if the paid subscription has started.
* @returns {Boolean}
*/
public started() {
return this.startsAt ? moment().isAfter(this.startsAt) : false;
}
/**
* Check if subscription is currently on trial.
* @return {Boolean}
*/
public onTrial() {
return this.trialEndsAt ? moment().isBefore(this.trialEndsAt) : false;
}
/**
* Check if the subscription is canceled.
* @returns {boolean}
*/
public canceled() {
return !!this.canceledAt;
}
/**
* Retrieves the subscription status.
* @returns {string}
*/
public status() {
return this.canceled()
? 'canceled'
: this.onTrial()
? 'on_trial'
: this.active()
? 'active'
: 'inactive';
}
/**
* Set new period from the given details.
* @param {string} invoiceInterval
* @param {number} invoicePeriod
* @param {string} start
*
* @return {Object}
*/
static setNewPeriod(invoiceInterval: any, invoicePeriod: any, start?: any) {
const period = new SubscriptionPeriod(
invoiceInterval,
invoicePeriod,
start,
);
const startsAt = period.getStartDate();
const endsAt = period.getEndDate();
return { startsAt, endsAt };
}
/**
* Renews subscription period.
* @Promise
*/
renew(invoiceInterval, invoicePeriod) {
const { startsAt, endsAt } = PlanSubscription.setNewPeriod(
invoiceInterval,
invoicePeriod,
);
return this.$query().update({ startsAt, endsAt });
}
/**
* Detarmines the subscription payment whether is failed.
* @returns {boolean}
*/
public isPaymentFailed() {
return this.paymentStatus === SubscriptionPaymentStatus.Failed;
}
/**
* Detarmines the subscription payment whether is succeed.
* @returns {boolean}
*/
public isPaymentSucceed() {
return this.paymentStatus === SubscriptionPaymentStatus.Succeed;
}
}

View File

@@ -0,0 +1,2 @@
export const SystemKnexConnection ='SystemKnexConnection';
export const SystemKnexConnectionConfigure = 'SystemKnexConnectionConfigure';

View File

@@ -0,0 +1,12 @@
import { Controller, Get, Post } from '@nestjs/common';
@Controller('/system_db')
export class SystemDatabaseController {
constructor() {}
@Post()
@Get()
ping(){
}
}

View File

@@ -0,0 +1,49 @@
import Knex from 'knex';
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
SystemKnexConnection,
SystemKnexConnectionConfigure,
} from './SystemDB.constants';
import { SystemDatabaseController } from './SystemDB.controller';
import { knexSnakeCaseMappers } from 'objection';
const providers = [
{
provide: SystemKnexConnectionConfigure,
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
client: configService.get('systemDatabase.client'),
connection: {
host: configService.get('systemDatabase.host'),
user: configService.get('systemDatabase.user'),
password: configService.get('systemDatabase.password'),
database: configService.get('systemDatabase.databaseName'),
charset: 'utf8',
},
migrations: {
directory: configService.get('systemDatabase.migrationDir'),
},
seeds: {
directory: configService.get('systemDatabase.seedsDir'),
},
pool: { min: 0, max: 7 },
...knexSnakeCaseMappers({ upperCase: true }),
}),
},
{
provide: SystemKnexConnection,
inject: [SystemKnexConnectionConfigure],
useFactory: (knexConfig) => {
return Knex(knexConfig);
},
},
];
@Global()
@Module({
providers: [...providers],
exports: [...providers],
controllers: [SystemDatabaseController],
})
export class SystemDatabaseModule {}

View File

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

View File

@@ -0,0 +1,34 @@
import { Knex } from 'knex';
import { Model } from 'objection';
import { Global, Module } from '@nestjs/common';
import { PlanSubscription } from '@/modules/Subscription/models/PlanSubscription';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { SystemKnexConnection } from '../SystemDB/SystemDB.constants';
import { SystemModelsConnection } from './SystemModels.constants';
import { SystemUser } from '../models/SystemUser';
const models = [SystemUser, PlanSubscription, TenantModel];
const modelProviders = models.map((model) => {
return {
provide: model.name,
useValue: model,
};
});
const providers = [
...modelProviders,
{
provide: SystemModelsConnection,
inject: [SystemKnexConnection],
useFactory: async (systemKnex: Knex) => {
Model.knex(systemKnex);
},
},
];
@Global()
@Module({
providers: [...providers],
exports: [...providers],
})
export class SystemModelsModule {}

View File

@@ -0,0 +1,3 @@
import { BaseModel } from 'src/models/Model';
export class SystemModel extends BaseModel {}

View File

@@ -0,0 +1,14 @@
import { BaseModel } from 'src/models/Model';
export class SystemUser extends BaseModel {
public readonly firstName: string;
public readonly lastName: string;
public readonly active: boolean;
public readonly password: string;
public readonly email: string;
public readonly tenantId: number;
static get tableName() {
return 'users';
}
}

View File

@@ -0,0 +1,12 @@
import { BaseModel } from 'src/models/Model';
export class TenantModel extends BaseModel {
public readonly organizationId: string;
public readonly initializedAt: string;
public readonly seededAt: boolean;
public readonly builtAt: string;
static get tableName() {
return 'tenants';
}
}

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 {}