mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 13:50:31 +00:00
feat: wip migrate server to nestjs
This commit is contained in:
441
packages/server-nest/src/modules/Accounts/models/Account.ts
Normal file
441
packages/server-nest/src/modules/Accounts/models/Account.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
packages/server-nest/src/modules/App/App.controller.spec.ts
Normal file
22
packages/server-nest/src/modules/App/App.controller.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
packages/server-nest/src/modules/App/App.controller.ts
Normal file
12
packages/server-nest/src/modules/App/App.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
115
packages/server-nest/src/modules/App/App.module.ts
Normal file
115
packages/server-nest/src/modules/App/App.module.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
24
packages/server-nest/src/modules/App/App.service.ts
Normal file
24
packages/server-nest/src/modules/App/App.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
packages/server-nest/src/modules/Auth/Auth.constants.ts
Normal file
4
packages/server-nest/src/modules/Auth/Auth.constants.ts
Normal 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.',
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
32
packages/server-nest/src/modules/Auth/Jwt.guard.ts
Normal file
32
packages/server-nest/src/modules/Auth/Jwt.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
packages/server-nest/src/modules/Auth/Jwt.strategy.ts
Normal file
19
packages/server-nest/src/modules/Auth/Jwt.strategy.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
120
packages/server-nest/src/modules/Items/CreateItem.service.ts
Normal file
120
packages/server-nest/src/modules/Items/CreateItem.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
packages/server-nest/src/modules/Items/DeleteItem.service.ts
Normal file
67
packages/server-nest/src/modules/Items/DeleteItem.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
143
packages/server-nest/src/modules/Items/EditItem.service.ts
Normal file
143
packages/server-nest/src/modules/Items/EditItem.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
44
packages/server-nest/src/modules/Items/Item.controller.ts
Normal file
44
packages/server-nest/src/modules/Items/Item.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
111
packages/server-nest/src/modules/Items/Item.schema.ts
Normal file
111
packages/server-nest/src/modules/Items/Item.schema.ts
Normal 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>;
|
||||
285
packages/server-nest/src/modules/Items/ItemValidator.service.ts
Normal file
285
packages/server-nest/src/modules/Items/ItemValidator.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
141
packages/server-nest/src/modules/Items/Items.constants.ts
Normal file
141
packages/server-nest/src/modules/Items/Items.constants.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
19
packages/server-nest/src/modules/Items/Items.module.ts
Normal file
19
packages/server-nest/src/modules/Items/Items.module.ts
Normal 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 {}
|
||||
13
packages/server-nest/src/modules/Items/ServiceError.ts
Normal file
13
packages/server-nest/src/modules/Items/ServiceError.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class ItemCreatedEvent {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
26
packages/server-nest/src/modules/Items/models/Item.ts
Normal file
26
packages/server-nest/src/modules/Items/models/Item.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
22
packages/server-nest/src/modules/Search/SearchableMdel.ts
Normal file
22
packages/server-nest/src/modules/Search/SearchableMdel.ts
Normal 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 [];
|
||||
// }
|
||||
};
|
||||
77
packages/server-nest/src/modules/Settings/ModelSettings.ts
Normal file
77
packages/server-nest/src/modules/Settings/ModelSettings.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SystemKnexConnection ='SystemKnexConnection';
|
||||
export const SystemKnexConnectionConfigure = 'SystemKnexConnectionConfigure';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, Post } from '@nestjs/common';
|
||||
|
||||
@Controller('/system_db')
|
||||
export class SystemDatabaseController {
|
||||
constructor() {}
|
||||
|
||||
@Post()
|
||||
@Get()
|
||||
ping(){
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1 @@
|
||||
export const SystemModelsConnection = 'SystemModelsConnection';
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { BaseModel } from 'src/models/Model';
|
||||
|
||||
export class SystemModel extends BaseModel {}
|
||||
14
packages/server-nest/src/modules/System/models/SystemUser.ts
Normal file
14
packages/server-nest/src/modules/System/models/SystemUser.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>({})],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TENANCY_DB_CONNECTION = 'TENANCY_DB_CONNECTION';
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TenancyModelsConnection = 'TenancyModelsConnection';
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user