refactor: implement tenant database management and seeding utilities

This commit is contained in:
Ahmed Bouhuolia
2025-03-27 23:13:17 +02:00
parent 92d98ce1d3
commit 6461a2318f
54 changed files with 1497 additions and 272 deletions

View File

@@ -25,6 +25,9 @@
"@bigcapital/server": "*",
"@bigcapital/utils": "*",
"@liaoliaots/nestjs-redis": "^10.0.0",
"@aws-sdk/client-s3": "^3.576.0",
"@aws-sdk/s3-request-presigner": "^3.583.0",
"@casl/ability": "^5.4.3",
"@nestjs/bull": "^10.2.1",
"@nestjs/bullmq": "^10.2.2",
"@nestjs/cache-manager": "^2.2.2",
@@ -63,6 +66,7 @@
"knex": "^3.1.0",
"lamda": "^0.4.1",
"lodash": "^4.17.21",
"lru-cache": "^6.0.0",
"mathjs": "^9.4.0",
"moment": "^2.30.1",
"moment-range": "^4.0.2",

View File

@@ -0,0 +1,100 @@
import path from 'path';
import { sortBy } from 'lodash';
import fs from 'fs';
import { promisify } from 'util';
import { MigrateItem } from './interfaces';
import { importWebpackSeedModule } from './Utils';
import { DEFAULT_LOAD_EXTENSIONS } from './constants';
import { filterMigrations } from './MigrateUtils';
const readdir = promisify(fs.readdir);
class FsMigrations {
private sortDirsSeparately: boolean;
private migrationsPaths: string[];
private loadExtensions: string[];
/**
* Constructor method.
* @param migrationDirectories
* @param sortDirsSeparately
* @param loadExtensions
*/
constructor(
migrationDirectories: string[],
sortDirsSeparately: boolean,
loadExtensions: string[]
) {
this.sortDirsSeparately = sortDirsSeparately;
if (!Array.isArray(migrationDirectories)) {
migrationDirectories = [migrationDirectories];
}
this.migrationsPaths = migrationDirectories;
this.loadExtensions = loadExtensions || DEFAULT_LOAD_EXTENSIONS;
}
/**
* Gets the migration names
* @returns Promise<MigrateItem[]>
*/
public getMigrations(loadExtensions = null): Promise<MigrateItem[]> {
// Get a list of files in all specified migration directories
const readMigrationsPromises = this.migrationsPaths.map((configDir) => {
const absoluteDir = path.resolve(process.cwd(), configDir);
return readdir(absoluteDir).then((files) => ({
files,
configDir,
absoluteDir,
}));
});
return Promise.all(readMigrationsPromises).then((allMigrations) => {
const migrations = allMigrations.reduce((acc, migrationDirectory) => {
// When true, files inside the folder should be sorted
if (this.sortDirsSeparately) {
migrationDirectory.files = migrationDirectory.files.sort();
}
migrationDirectory.files.forEach((file) =>
acc.push({ file, directory: migrationDirectory.configDir })
);
return acc;
}, []);
// If true we have already sorted the migrations inside the folders
// return the migrations fully qualified
if (this.sortDirsSeparately) {
return filterMigrations(
this,
migrations,
loadExtensions || this.loadExtensions
);
}
return filterMigrations(
this,
sortBy(migrations, 'file'),
loadExtensions || this.loadExtensions
);
});
}
/**
* Retrieve the file name from given migrate item.
* @param {MigrateItem} migration
* @returns {string}
*/
public getMigrationName(migration: MigrateItem): string {
return migration.file;
}
/**
* Retrieve the migrate file content from given migrate item.
* @param {MigrateItem} migration
* @returns {string}
*/
public getMigration(migration: MigrateItem): string {
return importWebpackSeedModule(migration.file);
}
}
export { DEFAULT_LOAD_EXTENSIONS, FsMigrations };

View File

@@ -0,0 +1,193 @@
// @ts-nocheck
import { differenceWith } from 'lodash';
import path from 'path';
import { FsMigrations } from './FsMigrations';
import {
getTable,
getTableName,
getLockTableName,
getLockTableNameWithSchema,
} from './TableUtils';
import { ISeederConfig, MigrateItem } from './interfaces';
/**
* Get schema-aware schema builder for a given schema nam
* @param trxOrKnex
* @param {string} schemaName
* @returns
*/
function getSchemaBuilder(trxOrKnex, schemaName: string | null = null) {
return schemaName
? trxOrKnex.schema.withSchema(schemaName)
: trxOrKnex.schema;
}
/**
* Creates migration table of the given table name.
* @param {string} tableName
* @param {string} schemaName
* @param trxOrKnex
* @returns
*/
function createMigrationTable(
tableName: string,
schemaName: string,
trxOrKnex,
) {
return getSchemaBuilder(trxOrKnex, schemaName).createTable(
getTableName(tableName),
(t) => {
t.increments();
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
},
);
}
/**
* Creates a migration lock table of the given table name.
* @param {string} tableName
* @param {string} schemaName
* @param trxOrKnex
* @returns
*/
function createMigrationLockTable(
tableName: string,
schemaName: string,
trxOrKnex,
) {
return getSchemaBuilder(trxOrKnex, schemaName).createTable(tableName, (t) => {
t.increments('index').primary();
t.integer('is_locked');
});
}
/**
*
* @param tableName
* @param schemaName
* @param trxOrKnex
* @returns
*/
export function ensureMigrationTables(
tableName: string,
schemaName: string,
trxOrKnex,
) {
const lockTable = getLockTableName(tableName);
const lockTableWithSchema = getLockTableNameWithSchema(tableName, schemaName);
return getSchemaBuilder(trxOrKnex, schemaName)
.hasTable(tableName)
.then((exists) => {
return !exists && createMigrationTable(tableName, schemaName, trxOrKnex);
})
.then(() => {
return getSchemaBuilder(trxOrKnex, schemaName).hasTable(lockTable);
})
.then((exists) => {
return (
!exists && createMigrationLockTable(lockTable, schemaName, trxOrKnex)
);
})
.then(() => {
return getTable(trxOrKnex, lockTable, schemaName).select('*');
})
.then((data) => {
return (
!data.length &&
trxOrKnex.into(lockTableWithSchema).insert({ is_locked: 0 })
);
});
}
/**
* Lists all available migration versions, as a sorted array.
* @param migrationSource
* @param loadExtensions
* @returns
*/
function listAll(
migrationSource: FsMigrations,
loadExtensions,
): Promise<MigrateItem[]> {
return migrationSource.getMigrations(loadExtensions);
}
/**
* Lists all migrations that have been completed for the current db, as an array.
* @param {string} tableName
* @param {string} schemaName
* @param {} trxOrKnex
* @returns Promise<string[]>
*/
export async function listCompleted(
tableName: string,
schemaName: string,
trxOrKnex,
): Promise<string[]> {
const completedMigrations = await trxOrKnex
.from(getTableName(tableName, schemaName))
.orderBy('id')
.select('name');
return completedMigrations.map((migration) => {
return migration.name;
});
}
/**
* Gets the migration list from the migration directory specified in config, as well as
* the list of completed migrations to check what should be run.
*/
export function listAllAndCompleted(config: ISeederConfig, trxOrKnex) {
return Promise.all([
listAll(config.migrationSource, config.loadExtensions),
listCompleted(config.tableName, config.schemaName, trxOrKnex),
]);
}
/**
*
* @param migrationSource
* @param all
* @param completed
* @returns
*/
export function getNewMigrations(
migrationSource: FsMigrations,
all: MigrateItem[],
completed: string[],
): MigrateItem[] {
return differenceWith(all, completed, (allMigration, completedMigration) => {
return (
completedMigration === migrationSource.getMigrationName(allMigration)
);
});
}
function startsWithNumber(str) {
return /^\d/.test(str);
}
/**
*
* @param {FsMigrations} migrationSource -
* @param {MigrateItem[]} migrations -
* @param {string[]} loadExtensions -
* @returns
*/
export function filterMigrations(
migrationSource: FsMigrations,
migrations: MigrateItem[],
loadExtensions: string[],
) {
return migrations.filter((migration) => {
const migrationName = migrationSource.getMigrationName(migration);
const extension = path.extname(migrationName);
return (
loadExtensions.includes(extension) && startsWithNumber(migrationName)
);
});
}

View File

@@ -0,0 +1,223 @@
// @ts-nocheck
import { Knex } from 'knex';
import Bluebird from 'bluebird';
import { getTable, getTableName, getLockTableName } from './TableUtils';
import getMergedConfig from './SeederConfig';
import {
listAllAndCompleted,
getNewMigrations,
listCompleted,
ensureMigrationTables,
} from './MigrateUtils';
import { MigrateItem, SeedMigrationContext, ISeederConfig } from './interfaces';
import { FsMigrations } from './FsMigrations';
export class SeedMigration {
knex: Knex;
config: ISeederConfig;
migrationSource: FsMigrations;
context: SeedMigrationContext;
/**
* Constructor method.
* @param {Knex} knex - Knex instance.
* @param {SeedMigrationContext} context -
*/
constructor(knex: Knex, context: SeedMigrationContext) {
this.knex = knex;
this.config = getMergedConfig(this.knex.client.config.seeds, undefined);
this.migrationSource = this.config.migrationSource;
this.context = context;
}
/**
* Latest migration.
* @returns {Promise<void>}
*/
async latest(config = null): Promise<void> {
// Merges the configuration.
this.config = getMergedConfig(config, this.config);
// Ensure migration tables.
await ensureMigrationTables(this.config.tableName, null, this.knex);
// Retrieve all and completed migrations.
const [all, completed] = await listAllAndCompleted(this.config, this.knex);
// Retrieve the new migrations.
const migrations = getNewMigrations(this.migrationSource, all, completed);
// Run the latest migration on one batch.
return this.knex.transaction((trx: Knex.Transaction) => {
return this.runBatch(migrations, 'up', trx);
});
}
/**
* Add migration lock flag.
* @param {Knex.Transaction} trx
* @returns
*/
private migrateLockTable(trx: Knex.Transaction) {
const tableName = getLockTableName(this.config.tableName);
return getTable(this.knex, tableName, this.config.schemaName)
.transacting(trx)
.where('is_locked', '=', 0)
.update({ is_locked: 1 })
.then((rowCount) => {
if (rowCount != 1) {
throw new Error('Migration table is already locked');
}
});
}
/**
* Add migration lock flag.
* @param {Knex.Transaction} trx
* @returns
*/
private migrationLock(trx: Knex.Transaction) {
return this.migrateLockTable(trx);
}
/**
* Free the migration lock flag.
* @param {Knex.Transaction} trx
* @returns
*/
private freeLock(trx = this.knex): Promise<void> {
const tableName = getLockTableName(this.config.tableName);
return getTable(trx, tableName, this.config.schemaName).update({
is_locked: 0,
});
}
/**
* Returns the latest batch number.
* @param trx
* @returns
*/
private latestBatchNumber(trx = this.knex): number {
return trx
.from(getTableName(this.config.tableName, this.config.schemaName))
.max('batch as max_batch')
.then((obj) => obj[0].max_batch || 0);
}
/**
* Runs a batch of `migrations` in a specified `direction`, saving the
* appropriate database information as the migrations are run.
* @param {number} batchNo
* @param {MigrateItem[]} migrations
* @param {string} direction
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
private waterfallBatch(
batchNo: number,
migrations: MigrateItem[],
direction: string,
trx: Knex.Transaction,
): Promise<void> {
const { tableName } = this.config;
return Bluebird.each(migrations, (migration) => {
const name = this.migrationSource.getMigrationName(migration);
return this.migrationSource
.getMigration(migration)
.then((migrationContent) =>
this.runMigrationContent(migrationContent.default, direction, trx),
)
.then(() => {
if (direction === 'up') {
return trx.into(getTableName(tableName)).insert({
name,
batch: batchNo,
migration_time: new Date(),
});
}
if (direction === 'down') {
return trx.from(getTableName(tableName)).where({ name }).del();
}
});
});
}
/**
* Runs and builds the given migration class.
*/
private runMigrationContent(Migration, direction, trx) {
const instance = new Migration(trx);
if (this.context.i18n) {
instance.setI18n(this.context.i18n);
}
instance.setTenant(this.context.tenant);
return instance[direction](trx);
}
/**
* Validates some migrations by requiring and checking for an `up` and `down`function.
* @param {MigrateItem} migration
* @returns {MigrateItem}
*/
async validateMigrationStructure(migration: MigrateItem): MigrateItem {
const migrationName = this.migrationSource.getMigrationName(migration);
// maybe promise
const migrationContent = await this.migrationSource.getMigration(migration);
if (
typeof migrationContent.up !== 'function' ||
typeof migrationContent.down !== 'function'
) {
throw new Error(
`Invalid migration: ${migrationName} must have both an up and down function`,
);
}
return migration;
}
/**
* Run a batch of current migrations, in sequence.
* @param {MigrateItem[]} migrations
* @param {string} direction
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
private async runBatch(
migrations: MigrateItem[],
direction: string,
trx: Knex.Transaction,
): Promise<void> {
// Adds flag to migration lock.
await this.migrationLock(trx);
// When there is a wrapping transaction, some migrations
// could have been done while waiting for the lock:
const completed = await listCompleted(
this.config.tableName,
this.config.schemaName,
trx,
);
// Differentiate between all and completed to get new migrations.
const newMigrations = getNewMigrations(
this.config.migrationSource,
migrations,
completed,
);
// Retrieve the latest batch number.
const batchNo = await this.latestBatchNumber(trx);
// Increment the next batch number.
const newBatchNo = direction === 'up' ? batchNo + 1 : batchNo;
// Run all migration files in waterfall.
await this.waterfallBatch(newBatchNo, newMigrations, direction, trx);
// Free the migration lock flag.
await this.freeLock(trx);
}
}

View File

@@ -0,0 +1,11 @@
export class Seeder {
knex: any;
constructor(knex) {
this.knex = knex;
}
up(knex) {}
down(knex) {}
}

View File

@@ -0,0 +1,44 @@
import { DEFAULT_LOAD_EXTENSIONS, FsMigrations } from './FsMigrations';
const CONFIG_DEFAULT = Object.freeze({
extension: 'js',
loadExtensions: DEFAULT_LOAD_EXTENSIONS,
tableName: 'knex_migrations',
schemaName: null,
directory: './migrations',
disableTransactions: false,
disableMigrationsListValidation: false,
sortDirsSeparately: false,
});
export default function getMergedConfig(config, currentConfig) {
// config is the user specified config, mergedConfig has defaults and current config
// applied to it.
const mergedConfig = {
...CONFIG_DEFAULT,
...(currentConfig || {}),
...config,
};
if (
config &&
// If user specifies any FS related config,
// clear specified migrationSource to avoid ambiguity
(config.directory ||
config.sortDirsSeparately !== undefined ||
config.loadExtensions)
) {
mergedConfig.migrationSource = null;
}
// If the user has not specified any configs, we need to
// default to fs migrations to maintain compatibility
if (!mergedConfig.migrationSource) {
mergedConfig.migrationSource = new FsMigrations(
mergedConfig.directory,
mergedConfig.sortDirsSeparately,
mergedConfig.loadExtensions
);
}
return mergedConfig;
}

View File

@@ -0,0 +1,43 @@
/**
* Get schema-aware query builder for a given table and schema name.
* @param {Knex} trxOrKnex -
* @param {string} tableName -
* @param {string} schemaName -
* @returns {string}
*/
export function getTable(trx, tableName: string, schemaName = null) {
return schemaName ? trx(tableName).withSchema(schemaName) : trx(tableName);
}
/**
* Get schema-aware table name.
* @param {string} tableName -
* @returns {string}
*/
export function getTableName(tableName: string, schemaName = null): string {
return schemaName ? `${schemaName}.${tableName}` : tableName;
}
/**
* Retrieve the lock table name from given migration table name.
* @param {string} tableName
* @returns {string}
*/
export function getLockTableName(tableName: string): string {
return `${tableName}_lock`;
}
/**
* Retireve the lock table name from ginve migration table name with schema.
* @param {string} tableName
* @param {string} schemaName
* @returns {string}
*/
export function getLockTableNameWithSchema(
tableName: string,
schemaName = null
): string {
return schemaName
? `${schemaName} + ${getLockTableName(tableName)}`
: getLockTableName(tableName);
}

View File

@@ -0,0 +1,25 @@
import { Seeder } from "./Seeder";
export class TenantSeeder extends Seeder{
public knex: any;
public i18n: i18nAPI;
public models: any;
public tenant: any;
constructor(knex) {
super(knex);
this.knex = knex;
}
setI18n(i18n) {
this.i18n = i18n;
}
setModels(models) {
this.models = models;
}
setTenant(tenant) {
this.tenant = tenant;
}
}

View File

@@ -0,0 +1,43 @@
// @ts-nocheck
import fs from 'fs';
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
/**
* Detarmines the module type of the given file path.
* @param {string} filepath
* @returns {boolean}
*/
async function isModuleType(filepath: string): boolean {
if (process.env.npm_package_json) {
// npm >= 7.0.0
const packageJson = JSON.parse(
await readFile(process.env.npm_package_json, 'utf-8'),
);
if (packageJson.type === 'module') {
return true;
}
}
return process.env.npm_package_type === 'module' || filepath.endsWith('.mjs');
}
/**
* Imports content of the given file path.
* @param {string} filepath
* @returns
*/
export async function importFile(filepath: string): any {
return (await isModuleType(filepath))
? import(require('url').pathToFileURL(filepath))
: require(filepath);
}
/**
*
* @param {string} moduleName
* @returns
*/
export async function importWebpackSeedModule(moduleName: string): any {
return import(`@/database/seeds/core/${moduleName}`);
}

View File

@@ -0,0 +1,12 @@
// Default load extensions.
export const DEFAULT_LOAD_EXTENSIONS = [
'.co',
'.coffee',
'.eg',
'.iced',
'.js',
'.cjs',
'.litcoffee',
'.ls',
'.ts',
];

View File

@@ -0,0 +1,20 @@
import { TenantModel } from '@/modules/System/models/TenantModel';
export interface FsMigrations {}
export interface ISeederConfig {
tableName: string;
migrationSource: FsMigrations;
schemaName?: string;
loadExtensions: string[];
}
export interface MigrateItem {
file: string;
directory: string;
}
export interface SeedMigrationContext {
i18n: any;
tenant: TenantModel;
}

View File

@@ -190,7 +190,7 @@ import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module
RolesModule,
SubscriptionModule,
OrganizationModule,
TenantDBManagerModule
TenantDBManagerModule,
],
controllers: [AppController],
providers: [

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { DashboardService } from './Dashboard.service';
import { FeaturesModule } from '../Features/Features.module';
import { DashboardController } from './Dashboard.controller';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
@Module({
imports: [FeaturesModule],
providers: [DashboardService],
providers: [DashboardService, TenancyContext],
controllers: [DashboardController],
})
export class DashboardModule {}

View File

@@ -3,7 +3,6 @@ import { omit, sumBy } from 'lodash';
import * as moment from 'moment';
import { Inject, Injectable } from '@nestjs/common';
import {
IManualJournalDTO,
IManualJournalEventEditedPayload,
IManualJournalEditingPayload,
} from '../types/ManualJournals.types';

View File

@@ -6,13 +6,15 @@ import { OrganizationController } from './Organization.controller';
import { BullModule } from '@nestjs/bullmq';
import { OrganizationBuildQueue } from './Organization.types';
import { OrganizationBuildProcessor } from './processors/OrganizationBuild.processor';
import { CommandOrganizationValidators } from './commands/CommandOrganizationValidators.service';
@Module({
providers: [
GetCurrentOrganizationService,
BuildOrganizationService,
UpdateOrganizationService,
OrganizationBuildProcessor
OrganizationBuildProcessor,
CommandOrganizationValidators,
],
imports: [BullModule.registerQueue({ name: OrganizationBuildQueue })],
controllers: [OrganizationController],

View File

@@ -1,4 +1,6 @@
import { TenantJobPayload } from '@/interfaces/Tenant';
import { SystemUser } from '../System/models/SystemUser';
import { BuildOrganizationDto } from './dtos/Organization.dto';
export interface IOrganizationSetupDTO {
organizationName: string;
@@ -53,6 +55,6 @@ export interface IOrganizationBuiltEventPayload {
export const OrganizationBuildQueue = 'OrganizationBuildQueue';
export const OrganizationBuildQueueJob = 'OrganizationBuildQueueJob';
export interface OrganizationBuildQueueJobPayload {
buildDto: IOrganizationBuildDTO;
}
export interface OrganizationBuildQueueJobPayload extends TenantJobPayload {
buildDto: BuildOrganizationDto;
}

View File

@@ -1,5 +1,6 @@
import { defaultTo } from 'lodash';
import { IOrganizationBuildDTO } from './Organization.types';
import { BuildOrganizationDto } from './dtos/Organization.dto';
/**
* Transformes build DTO object.
@@ -7,8 +8,8 @@ import { IOrganizationBuildDTO } from './Organization.types';
* @returns {IOrganizationBuildDTO}
*/
export const transformBuildDto = (
buildDTO: IOrganizationBuildDTO,
): IOrganizationBuildDTO => {
buildDTO: BuildOrganizationDto,
): BuildOrganizationDto => {
return {
...buildDTO,
dateFormat: defaultTo(buildDTO.dateFormat, 'DD MMM yyyy'),

View File

@@ -1,57 +1,62 @@
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import {
IOrganizationBuildDTO,
IOrganizationBuildEventPayload,
IOrganizationBuiltEventPayload,
OrganizationBuildQueue,
OrganizationBuildQueueJob,
OrganizationBuildQueueJobPayload,
} from '../Organization.types';
import { Injectable } from '@nestjs/common';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { throwIfTenantInitizalized, throwIfTenantIsBuilding } from '../Organization/_utils';
import {
throwIfTenantInitizalized,
throwIfTenantIsBuilding,
} from '../Organization/_utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TenantsManagerService } from '@/modules/TenantDBManager/TenantsManager';
import { events } from '@/common/events/events';
import { transformBuildDto } from '../Organization.utils';
import { BuildOrganizationDto } from '../dtos/Organization.dto';
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
@Injectable()
export class BuildOrganizationService {
constructor(
private readonly eventPublisher: EventEmitter2,
private readonly tenantsManager: TenantsManagerService,
private readonly tenancyContext: TenancyContext
private readonly tenancyContext: TenancyContext,
private readonly tenantRepository: TenantRepository,
@InjectQueue(OrganizationBuildQueue)
private readonly computeItemCostProcessor: Queue,
) {}
/**
* Builds the database schema and seed data of the given organization id.
* @param {srting} organizationId
* @param {string} organizationId
* @return {Promise<void>}
*/
public async build(
buildDTO: BuildOrganizationDto,
): Promise<void> {
public async build(buildDTO: BuildOrganizationDto): Promise<void> {
const tenant = await this.tenancyContext.getTenant();
const systemUser = await this.tenancyContext.getSystemUser();
// Throw error if the tenant is already initialized.
throwIfTenantInitizalized(tenant);
// Drop the database if is already exists.
await this.tenantsManager.dropDatabaseIfExists(tenant);
// Creates a new database.
await this.tenantsManager.createDatabase(tenant);
// Migrate the tenant.
await this.tenantsManager.migrateTenant(tenant);
// Migrated tenant.
const migratedTenant = await tenant.$query().withGraphFetched('metadata');
// Creates a tenancy object from given tenant model.
const tenancyContext =
this.tenantsManager.getSeedMigrationContext(migratedTenant);
// const tenancyContext =
// this.tenantsManager.getSeedMigrationContext(migratedTenant);
// Seed tenant.
await this.tenantsManager.seedTenant(migratedTenant, tenancyContext);
await this.tenantsManager.seedTenant(migratedTenant, {});
// Throws `onOrganizationBuild` event.
await this.eventPublisher.emitAsync(events.organization.build, {
@@ -60,12 +65,12 @@ export class BuildOrganizationService {
systemUser,
} as IOrganizationBuildEventPayload);
// Markes the tenant as completed builing.
await Tenant.markAsBuilt(tenantId);
await Tenant.markAsBuildCompleted(tenantId);
// Marks the tenant as completed builing.
await this.tenantRepository.markAsBuilt().findById(tenant.id);
await this.tenantRepository.markAsBuildCompleted().findById(tenant.id);
//
await this.flagTenantDBBatch(tenantId);
// Flags the tenant database batch.
await this.tenantRepository.flagTenantDBBatch().findById(tenant.id);
// Triggers the organization built event.
await this.eventPublisher.emitAsync(events.organization.built, {
@@ -75,13 +80,12 @@ export class BuildOrganizationService {
/**
*
* @param {number} tenantId
* @param {IOrganizationBuildDTO} buildDTO
* @returns
* @param {BuildOrganizationDto} buildDTO
* @returns {Promise<{ nextRunAt: Date; jobId: string }>} - Returns the next run date and job id.
*/
async buildRunJob(
buildDTO: BuildOrganizationDto,
) {
): Promise<{ nextRunAt: Date; jobId: string }> {
const tenant = await this.tenancyContext.getTenant();
const systemUser = await this.tenancyContext.getSystemUser();
@@ -91,27 +95,26 @@ export class BuildOrganizationService {
// Throw error if tenant is currently building.
throwIfTenantIsBuilding(tenant);
// Transformes build DTO object.
// Transforms build DTO object.
const transformedBuildDTO = transformBuildDto(buildDTO);
// Saves the tenant metadata.
await tenant.saveMetadata(transformedBuildDTO);
await this.tenantRepository.saveMetadata(tenant.id, transformedBuildDTO);
// Send welcome mail to the user.
const jobMeta = await this.agenda.now('organization-setup', {
tenantId,
buildDTO,
authorizedUser,
});
// Transformes the mangodb id to string.
const jobId = new ObjectId(jobMeta.attrs._id).toString();
// Markes the tenant as currently building.
await Tenant.markAsBuilding(tenantId, jobId);
const jobMeta = await this.computeItemCostProcessor.add(
OrganizationBuildQueueJob,
{
organizationId: tenant.organizationId,
userId: systemUser.id,
buildDto: transformedBuildDTO,
} as OrganizationBuildQueueJobPayload,
);
// Marks the tenant as currently building.
await this.tenantRepository.markAsBuilding(jobMeta.id).findById(tenant.id);
return {
nextRunAt: jobMeta.attrs.nextRunAt,
jobId: jobMeta.attrs._id,
nextRunAt: jobMeta.data.nextRunAt,
jobId: jobMeta.data.id,
};
}
@@ -123,16 +126,4 @@ export class BuildOrganizationService {
public async revertBuildRunJob() {
// await Tenant.markAsBuildCompleted(tenantId, jobId);
}
/**
* Adds organization database latest batch number.
* @param {number} tenantId
* @param {number} version
*/
public async flagTenantDBBatch(tenantId: number) {
await Tenant.query()
.update({
databaseBatch: config.databaseBatch,
})
.where({ id: tenantId });
}
}

View File

@@ -0,0 +1,42 @@
import { ServiceError } from '@/modules/Items/ServiceError';
import { OrganizationBaseCurrencyLocking } from '../Organization/OrganizationBaseCurrencyLocking.service';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { Injectable } from '@nestjs/common';
import { ERRORS } from '../Organization.constants';
@Injectable()
export class CommandOrganizationValidators {
constructor(
private readonly baseCurrencyMutateLocking: OrganizationBaseCurrencyLocking,
) {}
/**
* Throw base currency mutate locked error.
*/
throwBaseCurrencyMutateLocked() {
throw new ServiceError(ERRORS.BASE_CURRENCY_MUTATE_LOCKED);
}
/**
* Validate mutate base currency ability.
* @param {Tenant} tenant -
* @param {string} newBaseCurrency -
* @param {string} oldBaseCurrency -
*/
async validateMutateBaseCurrency(
tenant: TenantModel,
newBaseCurrency: string,
oldBaseCurrency: string,
) {
if (tenant.isReady && newBaseCurrency !== oldBaseCurrency) {
const isLocked =
await this.baseCurrencyMutateLocking.isBaseCurrencyMutateLocked(
tenant.id,
);
if (isLocked) {
this.throwBaseCurrencyMutateLocked();
}
}
}
}

View File

@@ -1,23 +1,27 @@
import { TenancyContext } from "@/modules/Tenancy/TenancyContext.service";
import { UpdateOrganizationDto } from "../dtos/Organization.dto";
import { throwIfTenantNotExists } from "../Organization/_utils";
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { UpdateOrganizationDto } from '../dtos/Organization.dto';
import { throwIfTenantNotExists } from '../Organization/_utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { CommandOrganizationValidators } from './CommandOrganizationValidators.service';
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
import { Injectable } from '@nestjs/common';
@Injectable()
export class UpdateOrganizationService {
constructor(
private readonly tenancyContext: TenancyContext
) {
private readonly tenancyContext: TenancyContext,
private readonly eventEmitter: EventEmitter2,
private readonly commandOrganizationValidators: CommandOrganizationValidators,
}
private readonly tenantRepository: TenantRepository,
) {}
/**
* Updates organization information.
* @param {ITenant} tenantId
* @param {IOrganizationUpdateDTO} organizationDTO
* @param {UpdateOrganizationDto} organizationDTO
*/
public async execute(
organizationDTO: UpdateOrganizationDto,
): Promise<void> {
public async execute(organizationDTO: UpdateOrganizationDto): Promise<void> {
const tenant = await this.tenancyContext.getTenant(true);
// Throw error if the tenant not exists.
@@ -25,23 +29,22 @@ export class UpdateOrganizationService {
// Validate organization transactions before mutate base currency.
if (organizationDTO.baseCurrency) {
await this.validateMutateBaseCurrency(
await this.commandOrganizationValidators.validateMutateBaseCurrency(
tenant,
organizationDTO.baseCurrency,
tenant.metadata?.baseCurrency,
);
}
await tenant.saveMetadata(organizationDTO);
await this.tenantRepository.saveMetadata(tenant.id, organizationDTO);
if (organizationDTO.baseCurrency !== tenant.metadata?.baseCurrency) {
// Triggers `onOrganizationBaseCurrencyUpdated` event.
await this.eventPublisher.emitAsync(
await this.eventEmitter.emitAsync(
events.organization.baseCurrencyUpdated,
{
tenantId,
organizationDTO,
},
);
}
}
}
}

View File

@@ -1,5 +1,6 @@
import moment from 'moment';
import {IsHexColor,
import moment from 'moment-timezone';
import {
IsHexColor,
IsIn,
IsISO31661Alpha2,
IsISO4217CurrencyCode,
@@ -8,69 +9,145 @@ import {IsHexColor,
} from 'class-validator';
import { MONTHS } from '../Organization/constants';
import { ACCEPTED_LOCALES, DATE_FORMATS } from '../Organization.constants';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BuildOrganizationDto {
@IsString()
@ApiProperty({
description: 'Organization name',
example: 'Acme Inc.',
})
name: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Industry of the organization',
example: 'Technology',
})
industry?: string;
@IsISO31661Alpha2()
@ApiProperty({
description: 'Country location in ISO 3166-1 alpha-2 format',
example: 'US',
})
location: string;
@IsISO4217CurrencyCode()
@ApiProperty({
description: 'Base currency in ISO 4217 format',
example: 'USD',
})
baseCurrency: string;
@IsIn(moment.tz.names())
@ApiProperty({
description: 'Timezone of the organization',
example: 'America/New_York',
})
timezone: string;
@IsIn(MONTHS)
@ApiProperty({
description: 'Starting month of fiscal year',
example: 'January',
})
fiscalYear: string;
@IsIn(ACCEPTED_LOCALES)
@ApiProperty({
description: 'Language/locale of the organization',
example: 'en-US',
})
language: string;
@IsOptional()
@IsIn(DATE_FORMATS)
@ApiPropertyOptional({
description: 'Date format used by the organization',
example: 'MM/DD/YYYY',
})
dateFormat?: string;
}
export class UpdateOrganizationDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Organization name',
example: 'Acme Inc.',
})
name?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Industry of the organization',
example: 'Technology',
})
industry?: string;
@IsOptional()
@IsISO31661Alpha2()
@ApiPropertyOptional({
description: 'Country location in ISO 3166-1 alpha-2 format',
example: 'US',
})
location?: string;
@IsOptional()
@IsISO4217CurrencyCode()
@ApiPropertyOptional({
description: 'Base currency in ISO 4217 format',
example: 'USD',
})
baseCurrency?: string;
@IsOptional()
@IsIn(moment.tz.names())
@ApiPropertyOptional({
description: 'Timezone of the organization',
example: 'America/New_York',
})
timezone?: string;
@IsOptional()
@IsIn(MONTHS)
@ApiPropertyOptional({
description: 'Starting month of fiscal year',
example: 'January',
})
fiscalYear?: string;
@IsOptional()
@IsIn(ACCEPTED_LOCALES)
@ApiPropertyOptional({
description: 'Language/locale of the organization',
example: 'en-US',
})
language?: string;
@IsOptional()
@IsIn(DATE_FORMATS)
@ApiPropertyOptional({
description: 'Date format used by the organization',
example: 'MM/DD/YYYY',
})
dateFormat?: string;
@IsOptional()
@ApiPropertyOptional({
description: 'Organization address details',
example: {
address_1: '123 Main St',
address_2: 'Suite 100',
postal_code: '10001',
city: 'New York',
stateProvince: 'NY',
phone: '+1-555-123-4567',
},
})
address?: {
address_1?: string;
address_2?: string;
@@ -82,13 +159,25 @@ export class UpdateOrganizationDto {
@IsOptional()
@IsHexColor()
@ApiPropertyOptional({
description: 'Primary brand color in hex format',
example: '#4285F4',
})
primaryColor?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Logo file key reference',
example: 'organizations/acme-logo-123456.png',
})
logoKey?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Organization tax identification number',
example: '12-3456789',
})
taxNumber?: string;
}

View File

@@ -8,6 +8,7 @@ import { ServiceError } from '../Items/ServiceError';
import { GetInvoicePaymentLinkMetaTransformer } from '../SaleInvoices/queries/GetInvoicePaymentLink.transformer';
import { ClsService } from 'nestjs-cls';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { TenantModel } from '../System/models/TenantModel';
@Injectable()
export class GetInvoicePaymentLinkMetadata {
@@ -21,6 +22,9 @@ export class GetInvoicePaymentLinkMetadata {
@Inject(PaymentLink.name)
private readonly paymentLinkModel: typeof PaymentLink,
@Inject(TenantModel.name)
private readonly systemTenantModel: typeof TenantModel,
) {}
/**
@@ -28,7 +32,8 @@ export class GetInvoicePaymentLinkMetadata {
* @param {string} linkId - Link id.
*/
async getInvoicePaymentLinkMeta(linkId: string) {
const paymentLink = await this.paymentLinkModel.query()
const paymentLink = await this.paymentLinkModel
.query()
.findOne('linkId', linkId)
.where('resourceType', 'SaleInvoice')
.throwIfNotFound();
@@ -42,8 +47,12 @@ export class GetInvoicePaymentLinkMetadata {
throw new ServiceError('PAYMENT_LINK_EXPIRED');
}
}
this.clsService.set('organizationId', paymentLink.tenantId);
this.clsService.set('userId', paymentLink.userId);
const tenant = await this.systemTenantModel
.query()
.findById(paymentLink.tenantId);
this.clsService.set('organizationId', tenant.organizationId);
// this.clsService.set('userId', paymentLink.userId);
const invoice = await this.saleInvoiceModel()
.query()

View File

@@ -1,14 +1,20 @@
import { Inject, Injectable } from '@nestjs/common';
import { SaleInvoicePdf } from '../SaleInvoices/queries/SaleInvoicePdf.service';
import { PaymentLink } from './models/PaymentLink';
import { ClsService } from 'nestjs-cls';
import { TenantModel } from '../System/models/TenantModel';
@Injectable()
export class GetPaymentLinkInvoicePdf {
constructor(
private readonly getSaleInvoicePdfService: SaleInvoicePdf,
private readonly clsService: ClsService,
@Inject(PaymentLink.name)
private readonly paymentLinkModel: typeof PaymentLink,
@Inject(TenantModel.name)
private readonly systemTenantModel: typeof TenantModel,
) {}
/**
@@ -19,15 +25,18 @@ export class GetPaymentLinkInvoicePdf {
async getPaymentLinkInvoicePdf(
paymentLinkId: string,
): Promise<[Buffer, string]> {
const paymentLink = await this.paymentLinkModel.query()
const paymentLink = await this.paymentLinkModel
.query()
.findOne('linkId', paymentLinkId)
.where('resourceType', 'SaleInvoice')
.throwIfNotFound();
const tenantId = paymentLink.tenantId;
await initalizeTenantServices(tenantId);
const saleInvoiceId = paymentLink.resourceId;
const tenant = await this.systemTenantModel
.query()
.findById(paymentLink.tenantId);
this.clsService.set('organizationId', tenant.organizationId);
return this.getSaleInvoicePdfService.getSaleInvoicePdf(saleInvoiceId);
}

View File

@@ -6,16 +6,21 @@ import { PaymentLinksController } from './PaymentLinks.controller';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { PaymentLink } from './models/PaymentLink';
import { StripePaymentModule } from '../StripePayment/StripePayment.module';
import { SaleInvoicesModule } from '../SaleInvoices/SaleInvoices.module';
import { GetInvoicePaymentLinkMetadata } from './GetInvoicePaymentLinkMetadata';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
const models = [InjectSystemModel(PaymentLink)];
@Module({
imports: [StripePaymentModule],
imports: [StripePaymentModule, SaleInvoicesModule],
providers: [
...models,
TenancyContext,
CreateInvoiceCheckoutSession,
GetPaymentLinkInvoicePdf,
PaymentLinksApplication,
GetInvoicePaymentLinkMetadata,
],
controllers: [PaymentLinksController],
exports: [...models, PaymentLinksApplication],

View File

@@ -0,0 +1,13 @@
import { ServiceError } from '../Items/ServiceError';
import { ERRORS } from './constants';
import { Role } from './models/Role.model';
/**
* Valdiates role is not predefined.
* @param {IRole} role - Role object.
*/
export const validateRoleNotPredefined = (role: Role) => {
if (role.predefined) {
throw new ServiceError(ERRORS.ROLE_PREFINED);
}
};

View File

@@ -7,6 +7,7 @@ import { events } from '@/common/events/events';
import { CreateRoleDto } from '../dtos/Role.dto';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Inject, Injectable } from '@nestjs/common';
import { validateInvalidPermissions } from '../utils';
@Injectable()
export class CreateRoleService {
@@ -25,7 +26,7 @@ export class CreateRoleService {
*/
public async createRole(createRoleDTO: CreateRoleDto) {
// Validates the invalid permissions.
this.validateInvalidPermissions(createRoleDTO.permissions);
validateInvalidPermissions(createRoleDTO.permissions);
// Transformes the permissions DTO.
const permissions = createRoleDTO.permissions;

View File

@@ -1,8 +1,5 @@
import { Knex } from 'knex';
import {
IRoleDeletedPayload,
} from '../Roles.types';
import { IRoleDeletedPayload } from '../Roles.types';
import { TenantModelProxy } from '../../System/models/TenantBaseModel';
import { Role } from '../models/Role.model';
import { RolePermission } from '../models/RolePermission.model';
@@ -10,30 +7,42 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { Inject, Injectable } from '@nestjs/common';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { validateRoleNotPredefined } from '../Roles.utils';
import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model';
import { ServiceError } from '@/modules/Items/ServiceError';
import { ERRORS } from '../constants';
@Injectable()
export class DeleteRoleService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
@Inject(RolePermission.name)
private readonly rolePermissionModel: TenantModelProxy<
typeof RolePermission
>,
@Inject(TenantUser.name)
private readonly tenantUserModel: TenantModelProxy<typeof TenantUser>,
) {}
/**
* Deletes the given role from the storage.
* @param {number} roleId - Role id.
*/
public async deleteRole(roleId: number): Promise<void> {
// Retrieve the given role or throw not found serice error.
const oldRole = await this.getRoleOrThrowError(roleId);
// Retrieve the given role or throw not found service error.
const oldRole = await this.roleModel()
.query()
.findById(roleId)
.throwIfNotFound();
// Validate role is not predefined.
this.validateRoleNotPredefined(oldRole);
validateRoleNotPredefined(oldRole);
// Validates the given role is not associated to any user.
await this.validateRoleNotAssociatedToUser(roleId);
@@ -57,5 +66,18 @@ export class DeleteRoleService {
} as IRoleDeletedPayload);
});
}
/**
* Validates the given role is not associated to any tenant users.
* @param {number} roleId
*/
private validateRoleNotAssociatedToUser = async (roleId: number) => {
const userAssociatedRole = await this.tenantUserModel()
.query()
.where('roleId', roleId);
// Throw service error if the role has associated users.
if (userAssociatedRole.length > 0) {
throw new ServiceError(ERRORS.CANNT_DELETE_ROLE_ASSOCIATED_TO_USERS);
}
};
}

View File

@@ -6,15 +6,14 @@ import { EditRoleDto } from '../dtos/Role.dto';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Role } from '../models/Role.model';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { validateInvalidPermissions } from '../utils';
@Injectable()
export class EditRoleService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
private readonly transformer: TransformerInjectable,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
@@ -27,11 +26,10 @@ export class EditRoleService {
*/
public async editRole(roleId: number, editRoleDTO: EditRoleDto) {
// Validates the invalid permissions.
this.validateInvalidPermissions(editRoleDTO.permissions);
validateInvalidPermissions(editRoleDTO.permissions);
// Retrieve the given role or throw not found serice error.
const oldRole = await this.getRoleOrThrowError(roleId);
const oldRole = await this.roleModel().query().findById(roleId);
const permissions = editRoleDTO.permissions;
// Updates the role on the storage.

View File

@@ -1,3 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
@@ -12,33 +13,62 @@ import {
export class CommandRolePermissionDto {
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'subject',
description: 'The subject of the permission',
})
subject: string;
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'read',
description: 'The action of the permission',
})
ability: string;
@IsBoolean()
@IsNotEmpty()
@ApiProperty({
example: true,
description: 'The value of the permission',
})
value: boolean;
@IsNumber()
@IsNotEmpty()
@ApiProperty({
example: 1,
description: 'The permission ID',
})
permissionId: number;
}
class CommandRoleDto {
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'admin',
description: 'The name of the role',
})
roleName: string;
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'Administrator',
description: 'The description of the role',
})
roleDescription: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CommandRolePermissionDto)
@MinLength(1)
@ApiProperty({
type: [CommandRolePermissionDto],
description: 'The permissions of the role',
})
permissions: Array<CommandRolePermissionDto>;
}

View File

@@ -1,12 +1,12 @@
import { Model, mixin } from 'objection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import RolePermission from './RolePermission.model';
import { RolePermission } from './RolePermission.model';
export class Role extends TenantBaseModel {
name: string;
description: string;
slug: string;
predefined: boolean;
permissions: Array<RolePermission>;
/**

View File

@@ -3,13 +3,20 @@ import { RoleTransformer } from './RoleTransformer';
import { Role } from '../models/Role.model';
import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service';
import { ServiceError } from '../../Items/ServiceError';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { CommandRolePermissionDto } from '../dtos/Role.dto';
import { ERRORS } from '../constants';
import { getInvalidPermissions } from '../utils';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetRoleService {
constructor(private readonly transformer: TransformerInjectable) {}
constructor(
private readonly transformer: TransformerInjectable,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
) {}
/**
* Retrieve the given role metadata.
@@ -17,11 +24,11 @@ export class GetRoleService {
* @returns {Promise<IRole>}
*/
public async getRole(roleId: number): Promise<Role> {
const role = await Role.query()
const role = await this.roleModel()
.query()
.findById(roleId)
.withGraphFetched('permissions');
this.throwRoleNotFound(role);
.withGraphFetched('permissions')
.throwIfNotFound();
return this.transformer.transform(role, new RoleTransformer());
}

View File

@@ -1,5 +1,9 @@
import { keyBy } from 'lodash';
import { ISubjectAbilitiesSchema } from './Roles.types';
import { CommandRolePermissionDto } from './dtos/Role.dto';
import { AbilitySchema } from './AbilitySchema';
import { ServiceError } from '../Items/ServiceError';
import { ERRORS } from './constants';
/**
* Transformes ability schema to map.
@@ -11,19 +15,19 @@ export function transformAbilitySchemaToMap(schema: ISubjectAbilitiesSchema[]) {
abilities: keyBy(item.abilities, 'key'),
extraAbilities: keyBy(item.extraAbilities, 'key'),
})),
'subject'
'subject',
);
}
/**
* Retrieve the invalid permissions from the given defined schema.
* @param {ISubjectAbilitiesSchema[]} schema
* @param permissions
* @returns
* @param {ISubjectAbilitiesSchema[]} schema
* @param permissions
* @returns
*/
export function getInvalidPermissions(
schema: ISubjectAbilitiesSchema[],
permissions
permissions,
) {
const schemaMap = transformAbilitySchemaToMap(schema);
@@ -40,3 +44,19 @@ export function getInvalidPermissions(
return false;
});
}
/**
* Validates the invalid given permissions.
* @param {ICreateRolePermissionDTO[]} permissions -
*/
export const validateInvalidPermissions = (
permissions: CommandRolePermissionDto[],
) => {
const invalidPerms = getInvalidPermissions(AbilitySchema, permissions);
if (invalidPerms.length > 0) {
throw new ServiceError(ERRORS.INVALIDATE_PERMISSIONS, null, {
invalidPermissions: invalidPerms,
});
}
};

View File

@@ -119,6 +119,6 @@ import { SaleInvoicesCost } from './SalesInvoicesCost';
InvoicePaymentsGLEntriesRewrite,
SaleInvoicesCost,
],
exports: [GetSaleInvoice, SaleInvoicesCost],
exports: [GetSaleInvoice, SaleInvoicesCost, SaleInvoicePdf],
})
export class SaleInvoicesModule {}

View File

@@ -9,6 +9,7 @@ import {
ISaleInvoiceDeletingPayload,
} from '../SaleInvoice.types';
import { events } from '@/common/events/events';
import { TransactionPaymentServiceEntry } from '@/modules/PaymentServices/models/TransactionPaymentServiceEntry.model';
@Injectable()
export class InvoicePaymentIntegrationSubscriber {
@@ -30,7 +31,7 @@ export class InvoicePaymentIntegrationSubscriber {
saleInvoice.paymentMethods?.filter((method) => method.enable) || [];
paymentMethods.map(
async (paymentMethod: PaymentIntegrationTransactionLink) => {
async (paymentMethod: TransactionPaymentServiceEntry) => {
const payload = {
...omit(paymentMethod, ['id']),
saleInvoiceId: saleInvoice.id,
@@ -57,7 +58,7 @@ export class InvoicePaymentIntegrationSubscriber {
oldSaleInvoice.paymentMethods?.filter((method) => method.enable) || [];
paymentMethods.map(
async (paymentMethod: PaymentIntegrationTransactionLink) => {
async (paymentMethod: TransactionPaymentServiceEntry) => {
const payload = {
...omit(paymentMethod, ['id']),
oldSaleInvoiceId: oldSaleInvoice.id,

View File

@@ -11,14 +11,24 @@ import { MarkSubscriptionPaymentFailed } from './commands/MarkSubscriptionPaymen
import { MarkSubscriptionPaymentSucceed } from './commands/MarkSubscriptionPaymentSuccessed.service';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { PlanSubscription } from './models/PlanSubscription';
import { MarkSubscriptionCanceled } from './commands/MarkSubscriptionCanceled.service';
import { MarkSubscriptionPlanChanged } from './commands/MarkSubscriptionChanged.service';
import { MarkSubscriptionResumedService } from './commands/MarkSubscriptionResumed.sevice';
import { Plan } from './models/Plan';
import { SubscriptionApplication } from './SubscriptionApplication';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { NewSubscriptionService } from './commands/NewSubscription.service';
import { GetSubscriptionsService } from './queries/GetSubscriptions.service';
import { GetLemonSqueezyCheckoutService } from './queries/GetLemonSqueezyCheckout.service';
const models = [InjectSystemModel(Plan), InjectSystemModel(PlanSubscription)]
const models = [InjectSystemModel(Plan), InjectSystemModel(PlanSubscription)];
@Module({
providers: [
...models,
TenancyContext,
NewSubscriptionService,
GetSubscriptionsService,
CancelLemonSubscription,
ChangeLemonSubscription,
ResumeLemonSubscription,
@@ -26,9 +36,14 @@ const models = [InjectSystemModel(Plan), InjectSystemModel(PlanSubscription)]
SubscribeFreeOnSignupCommunity,
TriggerInvalidateCacheOnSubscriptionChange,
MarkSubscriptionPaymentFailed,
MarkSubscriptionPaymentSucceed
MarkSubscriptionPaymentSucceed,
MarkSubscriptionCanceled,
MarkSubscriptionPlanChanged,
MarkSubscriptionResumedService,
SubscriptionApplication,
GetLemonSqueezyCheckoutService,
],
controllers: [SubscriptionsController, SubscriptionsLemonWebhook],
exports: [...models]
exports: [...models],
})
export class SubscriptionModule {}

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import { Controller, Post, Req } from '@nestjs/common';
import { LemonSqueezyWebhooks } from './webhooks/LemonSqueezyWebhooks';
@Controller('/webhooks/lemon')
@@ -15,6 +15,7 @@ export class SubscriptionsLemonWebhook {
async lemonWebhooks(@Req() req: Request) {
const data = req.body;
const signature = (req.headers['x-signature'] as string) ?? '';
// @ts-ignore
const rawBody = req.rawBody;
await this.lemonWebhooksService.handlePostWebhook(rawBody, data, signature);

View File

@@ -1,18 +1,17 @@
import { SubscriptionPayload } from '@/interfaces/SubscriptionPlan';
import { Inject, Injectable } from '@nestjs/common';
import { PlanSubscription } from '../models/PlanSubscription';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { SubscriptionPayload } from '@/interfaces/SubscriptionPlan';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { Plan } from '../models/Plan';
import { NotAllowedChangeSubscriptionPlan } from '../exceptions/NotAllowedChangeSubscriptionPlan';
import { PlanSubscriptionRepository } from '../repositories/PlanSubscription.repository';
@Injectable()
export class NewSubscriptionService {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly tenancyContext: TenancyContext,
@Inject(PlanSubscription.name)
private readonly planSubscriptionModel: typeof PlanSubscription,
private readonly subscriptionRepository: PlanSubscriptionRepository,
@Inject(Plan.name)
private readonly planModel: typeof Plan,
@@ -56,7 +55,8 @@ export class NewSubscriptionService {
// No stored past tenant subscriptions create new one.
} else {
await tenant.newSubscription(
await this.subscriptionRepository.newSubscription(
tenant.id,
plan.id,
invoiceInterval,
invoicePeriod,

View File

@@ -0,0 +1,6 @@
export class NotAllowedChangeSubscriptionPlan extends Error {
constructor(message: string = 'Not allowed to change subscription plan.') {
super(message);
this.name = 'NotAllowedChangeSubscriptionPlan';
}
}

View File

@@ -4,8 +4,8 @@ import { Model, mixin } from 'objection';
export class Plan extends mixin(SystemModel) {
public readonly slug: string;
public readonly price: number;
public readonly invoiceInternal: number;
public readonly invoicePeriod: string;
public readonly invoiceInternal: 'month' | 'year';
public readonly invoicePeriod: number;
public readonly trialPeriod: string;
public readonly trialInterval: number;

View File

@@ -5,13 +5,15 @@ 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;
public readonly planId: number;
public readonly lemonSubscriptionId!: string;
public readonly slug: string;
public readonly endsAt!: Date;
public readonly startsAt!: Date;
public readonly canceledAt!: Date;
public readonly trialEndsAt!: Date;
public readonly paymentStatus!: SubscriptionPaymentStatus;
public readonly tenantId!: number;
public readonly planId!: number;
/**
* Table name.

View File

@@ -0,0 +1,47 @@
import { TenantRepository } from '@/common/repository/TenantRepository';
import { SubscriptionPeriod } from '../SubscriptionPeriod';
import { PlanSubscription } from '../models/PlanSubscription';
import { Inject, Injectable } from '@nestjs/common';
import { SystemKnexConnection } from '@/modules/System/SystemDB/SystemDB.constants';
import { Knex } from 'knex';
import { PartialModelObject } from 'objection';
@Injectable()
export class PlanSubscriptionRepository extends TenantRepository {
constructor(
@Inject(SystemKnexConnection)
private readonly tenantDBKnex: Knex,
) {
super();
}
/**
* Gets the repository's model.
*/
get model(): typeof PlanSubscription {
return PlanSubscription.bindKnex(this.tenantDBKnex);
}
/**
* Records a new subscription for the associated tenant.
*/
newSubscription(
tenantId: number,
planId: number,
invoiceInterval: 'month' | 'year',
invoicePeriod: number,
subscriptionSlug: string,
payload?: { lemonSqueezyId?: string },
) {
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
const model: PartialModelObject<PlanSubscription> = {
tenantId,
slug: subscriptionSlug,
planId,
startsAt: period.getStartDate(),
endsAt: period.getEndDate(),
lemonSubscriptionId: payload?.lemonSqueezyId || null,
};
return this.model.query().insert({ ...model });
}
}

View File

@@ -8,7 +8,7 @@ export const ERRORS = {
};
export interface IOrganizationSubscriptionChanged {
lemonSubscriptionId: number;
lemonSubscriptionId: string;
newVariantId: number;
}

View File

@@ -1,26 +1,63 @@
import { BaseModel } from '@/models/Model';
import { Model } from 'objection';
import { TenantMetadata } from './TenantMetadataModel';
import { PlanSubscription } from '@/modules/Subscription/models/PlanSubscription';
export class TenantModel extends BaseModel {
public readonly organizationId: string;
public readonly initializedAt: string;
public readonly seededAt: boolean;
public readonly seededAt: string;
public readonly builtAt: string;
public readonly metadata: TenantMetadata;
public readonly buildJobId: string;
public readonly upgradeJobId: string;
public readonly databaseBatch: string;
public readonly subscriptions: Array<PlanSubscription>;
/**
* Table name.
*/
static tableName = 'tenants';
/**
* Virtual attributes.
* @returns {string[]}
*/
static get virtualAttributes() {
return ['isReady', 'isBuildRunning', 'isUpgradeRunning'];
}
/**
* Tenant is ready.
* @returns {boolean}
*/
get isReady() {
return !!(this.initializedAt && this.seededAt);
}
/**
* Determines the tenant whether is build currently running.
* @returns {boolean}
*/
get isBuildRunning() {
return !!this.buildJobId;
}
/**
* Determines the tenant whether is upgrade currently running.
* @returns {boolean}
*/
get isUpgradeRunning() {
return !!this.upgradeJobId;
}
/**
* Relations mappings.
*/
static get relationMappings() {
// const PlanSubscription = require('./Subscriptions/PlanSubscription');
const {
PlanSubscription,
} = require('../../Subscription/models/PlanSubscription');
const { TenantMetadata } = require('./TenantMetadataModel');
return {
@@ -32,6 +69,15 @@ export class TenantModel extends BaseModel {
to: 'tenants_metadata.tenantId',
},
},
subscriptions: {
relation: Model.HasManyRelation,
modelClass: PlanSubscription,
join: {
from: 'tenants.id',
to: 'subscription_plan_subscriptions.tenantId',
},
},
};
}
}

View File

@@ -0,0 +1,132 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import uniqid from 'uniqid';
import { TenantRepository as TenantBaseRepository } from '@/common/repository/TenantRepository';
import { SystemKnexConnection } from '../SystemDB/SystemDB.constants';
import { TenantModel } from '../models/TenantModel';
import { ConfigService } from '@nestjs/config';
import { TenantMetadata } from '../models/TenantMetadataModel';
@Injectable()
export class TenantRepository extends TenantBaseRepository {
constructor(
@Inject(SystemKnexConnection)
private readonly tenantDBKnex: Knex,
@Inject(TenantMetadata.name)
private readonly tenantMetadataModel: typeof TenantMetadata,
private readonly configService: ConfigService,
) {
super();
}
/**
* Gets the repository's model.
*/
get model(): typeof TenantModel {
return TenantModel.bindKnex(this.tenantDBKnex);
}
/**
* Creates a new tenant with random organization id.
*/
createWithUniqueOrgId(uniqId?: string) {
const organizationId = uniqid() || uniqId;
return this.model.query().insert({ organizationId });
}
/**
* Mark as seeded.
* @param {number} tenantId
*/
markAsSeeded() {
const seededAt = moment().toMySqlDateTime();
return this.model.query().update({ seededAt });
}
/**
* Mark the the given organization as initialized.
* @param {string} organizationId
*/
markAsInitialized() {
const initializedAt = moment().toMySqlDateTime();
return this.model.query().update({ initializedAt });
}
/**
* Marks the given tenant as built.
*/
markAsBuilt() {
const builtAt = moment().toMySqlDateTime();
return this.model.query().update({ builtAt });
}
/**
* Marks the given tenant as built.
* @param {string} buildJobId - The build job id.
*/
markAsBuilding(buildJobId: string) {
return this.model.query().update({ buildJobId });
}
/**
* Marks the given tenant as built.
*/
markAsBuildCompleted() {
return this.model.query().update({ buildJobId: null });
}
/**
* Marks the given tenant as upgrading.
* @param {number} tenantId
* @param {string} upgradeJobId
* @returns
*/
markAsUpgrading(tenantId, upgradeJobId) {
return this.model.query().update({ upgradeJobId }).where({ id: tenantId });
}
/**
* Marks the given tenant as upgraded.
* @param {number} tenantId
* @returns
*/
markAsUpgraded(tenantId) {
return this.model
.query()
.update({ upgradeJobId: null })
.where({ id: tenantId });
}
/**
* Saves the metadata of the given tenant.
* @param {number} tenantId - The tenant id.
* @param {Record<string, any>} metadata - The metadata to save.
*/
async saveMetadata(tenantId: number, metadata: Record<string, any>) {
const foundMetadata = await this.tenantMetadataModel
.query()
.findOne({ tenantId });
const updateOrInsert = foundMetadata ? 'patch' : 'insert';
return this.tenantMetadataModel
.query()
[updateOrInsert]({
tenantId,
...metadata,
})
.where({ tenantId });
}
/**
* Adds organization database latest batch number.
* @param {number} tenantId
* @param {number} version
*/
flagTenantDBBatch() {
return this.model.query().update({
databaseBatch: this.configService.get('databaseBatch'),
});
}
}

View File

@@ -1,4 +1,8 @@
import { Module } from '@nestjs/common';
import { TenantsManagerService } from './TenantsManager';
import { TenantDBManager } from './TenantDBManager';
@Module({})
export class TenantDBManagerModule {}
export class TenantDBManagerModule {
providers: [TenantsManagerService, TenantDBManager];
}

View File

@@ -4,8 +4,10 @@ import { TenantDBAlreadyExists } from './exceptions/TenantDBAlreadyExists';
import { sanitizeDatabaseName } from '@/utils/sanitize-database-name';
import { ConfigService } from '@nestjs/config';
import { SystemKnexConnection } from '../System/SystemDB/SystemDB.constants';
import { Inject } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModel } from '../System/models/TenantModel';
@Injectable()
export class TenantDBManager {
static knexCache: { [key: string]: Knex } = {};
@@ -17,20 +19,20 @@ export class TenantDBManager {
) {}
/**
* Retrieve the tenant database name.
* Retrieves the tenant database name.
* @return {string}
*/
private getDatabaseName(tenant: ITenant) {
private getDatabaseName(tenant: TenantModel) {
return sanitizeDatabaseName(
`${this.configService.get('tenant.db_name_prefix')}${tenant.organizationId}`,
);
}
/**
* Detarmines the tenant database weather exists.
* Determines the tenant database weather exists.
* @return {Promise<boolean>}
*/
public async databaseExists(tenant: ITenant) {
public async databaseExists(tenant: TenantModel) {
const databaseName = this.getDatabaseName(tenant);
const results = await this.systemKnex.raw(
'SELECT * FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = "' +
@@ -45,7 +47,7 @@ export class TenantDBManager {
* @throws {TenantAlreadyInitialized}
* @return {Promise<void>}
*/
public async createDatabase(tenant: ITenant): Promise<void> {
public async createDatabase(tenant: TenantModel): Promise<void> {
await this.throwErrorIfTenantDBExists(tenant);
const databaseName = this.getDatabaseName(tenant);
@@ -58,7 +60,7 @@ export class TenantDBManager {
* Dropdowns the tenant database if it was exist.
* @param {ITenant} tenant -
*/
public async dropDatabaseIfExists(tenant: ITenant) {
public async dropDatabaseIfExists(tenant: TenantModel) {
const isExists = await this.databaseExists(tenant);
if (!isExists) {
@@ -71,7 +73,7 @@ export class TenantDBManager {
* dropdowns the tenant's database.
* @param {ITenant} tenant
*/
public async dropDatabase(tenant: ITenant) {
public async dropDatabase(tenant: TenantModel) {
const databaseName = this.getDatabaseName(tenant);
await this.systemKnex.raw(`DROP DATABASE IF EXISTS ${databaseName}`);
@@ -81,7 +83,7 @@ export class TenantDBManager {
* Migrate tenant database schema to the latest version.
* @return {Promise<void>}
*/
public async migrate(tenant: ITenant): Promise<void> {
public async migrate(tenant: TenantModel): Promise<void> {
const knex = this.setupKnexInstance(tenant);
await knex.migrate.latest();
}
@@ -90,7 +92,7 @@ export class TenantDBManager {
* Seeds initial data to the tenant database.
* @return {Promise<void>}
*/
public async seed(tenant: ITenant): Promise<void> {
public async seed(tenant: TenantModel): Promise<void> {
const knex = this.setupKnexInstance(tenant);
await knex.migrate.latest({
@@ -103,7 +105,7 @@ export class TenantDBManager {
* Retrieve the knex instance of tenant.
* @return {Knex}
*/
private setupKnexInstance(tenant: ITenant) {
private setupKnexInstance(tenant: TenantModel) {
const key: string = `${tenant.id}`;
let knexInstance = TenantDBManager.knexCache[key];
@@ -134,7 +136,7 @@ export class TenantDBManager {
* Throws error if the tenant database already exists.
* @return {Promise<void>}
*/
async throwErrorIfTenantDBExists(tenant: ITenant) {
async throwErrorIfTenantDBExists(tenant: TenantModel) {
const isExists = await this.databaseExists(tenant);
if (isExists) {
throw new TenantDBAlreadyExists();

View File

@@ -1,49 +1,30 @@
import { Injectable } from "@nestjs/common";
import { TenantDBManager } from "./TenantDBManager";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { events } from "@/common/events/events";
// import { Container, Inject, Service } from 'typedi';
// import { ITenantManager, ITenant, ITenantDBManager } from '@/interfaces';
// import {
// EventDispatcherInterface,
// EventDispatcher,
// } from 'decorators/eventDispatcher';
// import {
// TenantAlreadyInitialized,
// TenantAlreadySeeded,
// TenantDatabaseNotBuilt,
// } from '@/exceptions';
// import TenantDBManager from '@/services/Tenancy/TenantDBManager';
// import events from '@/subscribers/events';
// import { Tenant } from '@/system/models';
// import { SeedMigration } from '@/lib/Seeder/SeedMigration';
// import i18n from '../../loaders/i18n';
// const ERRORS = {
// TENANT_ALREADY_CREATED: 'TENANT_ALREADY_CREATED',
// TENANT_NOT_EXISTS: 'TENANT_NOT_EXISTS',
// };
import { Injectable } from '@nestjs/common';
import { TenantDBManager } from './TenantDBManager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { TenantModel } from '../System/models/TenantModel';
import {
throwErrorIfTenantAlreadyInitialized,
throwErrorIfTenantAlreadySeeded,
throwErrorIfTenantNotBuilt,
} from './_utils';
import { SeedMigration } from '@/libs/migration-seed/SeedMigration';
import { TenantRepository } from '../System/repositories/Tenant.repository';
@Injectable()
export class TenantsManagerService {
constructor(
private readonly tenantDbManager: TenantDBManager,
private readonly eventEmitter: EventEmitter2
) {
}
private readonly eventEmitter: EventEmitter2,
private readonly tenantRepository: TenantRepository,
) {}
/**
* Creates a new teant with unique organization id.
* @param {ITenant} tenant
* @return {Promise<ITenant>}
* @return {Promise<TenantModel>}
*/
public async createTenant(): Promise<ITenant> {
const { tenantRepository } = this.sysRepositories;
const tenant = await tenantRepository.createWithUniqueOrgId();
return tenant;
public async createTenant(): Promise<TenantModel> {
return this.tenantRepository.createWithUniqueOrgId();
}
/**
@@ -51,8 +32,8 @@ export class TenantsManagerService {
* @param {ITenant} tenant -
* @return {Promise<void>}
*/
public async createDatabase(tenant: ITenant): Promise<void> {
this.throwErrorIfTenantAlreadyInitialized(tenant);
public async createDatabase(tenant: TenantModel): Promise<void> {
throwErrorIfTenantAlreadyInitialized(tenant);
await this.tenantDbManager.createDatabase(tenant);
@@ -63,17 +44,17 @@ export class TenantsManagerService {
* Drops the database if the given tenant.
* @param {number} tenantId
*/
async dropDatabaseIfExists(tenant: ITenant) {
async dropDatabaseIfExists(tenant: TenantModel) {
// Drop the database if exists.
await this.tenantDbManager.dropDatabaseIfExists(tenant);
}
/**
* Detarmines the tenant has database.
* Determines the tenant has database.
* @param {ITenant} tenant
* @returns {Promise<boolean>}
*/
public async hasDatabase(tenant: ITenant): Promise<boolean> {
public async hasDatabase(tenant: TenantModel): Promise<boolean> {
return this.tenantDbManager.databaseExists(tenant);
}
@@ -82,18 +63,18 @@ export class TenantsManagerService {
* @param {ITenant} tenant
* @return {Promise<void>}
*/
public async migrateTenant(tenant: ITenant): Promise<void> {
public async migrateTenant(tenant: TenantModel): Promise<void> {
// Throw error if the tenant already initialized.
this.throwErrorIfTenantAlreadyInitialized(tenant);
throwErrorIfTenantAlreadyInitialized(tenant);
// Migrate the database tenant.
await this.tenantDbManager.migrate(tenant);
// Mark the tenant as initialized.
await Tenant.markAsInitialized(tenant.id);
await this.tenantRepository.markAsInitialized().findById(tenant.id);
// Triggers `onTenantMigrated` event.
this.eventDispatcher.dispatch(events.tenantManager.tenantMigrated, {
this.eventEmitter.emitAsync(events.tenantManager.tenantMigrated, {
tenantId: tenant.id,
});
}
@@ -103,21 +84,21 @@ export class TenantsManagerService {
* @param {ITenant} tenant
* @return {Promise<void>}
*/
public async seedTenant(tenant: ITenant, tenancyContext): Promise<void> {
public async seedTenant(tenant: TenantModel, tenancyContext): Promise<void> {
// Throw error if the tenant is not built yet.
this.throwErrorIfTenantNotBuilt(tenant);
throwErrorIfTenantNotBuilt(tenant);
// Throw error if the tenant is not seeded yet.
this.throwErrorIfTenantAlreadySeeded(tenant);
throwErrorIfTenantAlreadySeeded(tenant);
// Seeds the organization database data.
await new SeedMigration(tenancyContext.knex, tenancyContext).latest();
// Mark the tenant as seeded in specific date.
await Tenant.markAsSeeded(tenant.id);
await this.tenantRepository.markAsSeeded().findById(tenant.id);
// Triggers `onTenantSeeded` event.
this.eventDispatcher.dispatch(events.tenantManager.tenantSeeded, {
this.eventEmitter.emitAsync(events.tenantManager.tenantSeeded, {
tenantId: tenant.id,
});
}
@@ -127,8 +108,8 @@ export class TenantsManagerService {
* @param {ITenant} tenant
* @returns {Knex}
*/
public setupKnexInstance(tenant: ITenant) {
return this.tenantDBManager.setupKnexInstance(tenant);
public setupKnexInstance(tenant: TenantModel) {
// return this.tenantDbManager.setupKnexInstance(tenant);
}
/**
@@ -137,55 +118,6 @@ export class TenantsManagerService {
* @returns {Knex}
*/
public getKnexInstance(tenantId: number) {
return this.tenantDBManager.getKnexInstance(tenantId);
}
/**
* Throws error if the tenant already seeded.
* @throws {TenantAlreadySeeded}
*/
private throwErrorIfTenantAlreadySeeded(tenant: ITenant) {
if (tenant.seededAt) {
throw new TenantAlreadySeeded();
}
}
/**
* Throws error if the tenant database is not built yut.
* @param {ITenant} tenant
*/
private throwErrorIfTenantNotBuilt(tenant: ITenant) {
if (!tenant.initializedAt) {
throw new TenantDatabaseNotBuilt();
}
}
/**
* Throws error if the tenant already migrated.
* @throws {TenantAlreadyInitialized}
*/
private throwErrorIfTenantAlreadyInitialized(tenant: ITenant) {
if (tenant.initializedAt) {
throw new TenantAlreadyInitialized();
}
}
/**
* Initialize seed migration contxt.
* @param {ITenant} tenant
* @returns
*/
public getSeedMigrationContext(tenant: ITenant) {
// Initialize the knex instance.
const knex = this.setupKnexInstance(tenant);
const i18nInstance = i18n();
i18nInstance.setLocale(tenant.metadata.language);
return {
knex,
i18n: i18nInstance,
tenant,
};
return this.tenantDbManager.getKnexInstance(tenantId);
}
}

View File

@@ -0,0 +1,34 @@
import { TenantModel } from '../System/models/TenantModel';
import { TenantAlreadyInitialized } from './exceptions/TenantAlreadyInitialized';
import { TenantAlreadySeeded } from './exceptions/TenantAlreadySeeded';
import { TenantDatabaseNotBuilt } from './exceptions/TenantDatabaseNotBuilt';
/**
* Throws error if the tenant already seeded.
* @throws {TenantAlreadySeeded}
*/
export const throwErrorIfTenantAlreadySeeded = (tenant: TenantModel) => {
if (tenant.seededAt) {
throw new TenantAlreadySeeded();
}
};
/**
* Throws error if the tenant database is not built yut.
* @param {ITenant} tenant
*/
export const throwErrorIfTenantNotBuilt = (tenant: TenantModel) => {
if (!tenant.initializedAt) {
throw new TenantDatabaseNotBuilt();
}
};
/**
* Throws error if the tenant already migrated.
* @throws {TenantAlreadyInitialized}
*/
export const throwErrorIfTenantAlreadyInitialized = (tenant: TenantModel) => {
if (tenant.initializedAt) {
throw new TenantAlreadyInitialized();
}
};

View File

@@ -0,0 +1,6 @@
export class TenantAlreadyInitialized extends Error {
constructor(description: string = 'Tenant is already initialized') {
super(description);
this.name = 'TenantAlreadyInitialized';
}
}

View File

@@ -0,0 +1,6 @@
export class TenantAlreadySeeded extends Error {
constructor(description: string = 'Tenant is already seeded') {
super(description);
this.name = 'TenantAlreadySeeded';
}
}

View File

@@ -1,6 +1,6 @@
export class TenantDBAlreadyExists {
constructor() {
export class TenantDBAlreadyExists extends Error {
constructor(description: string = 'Tenant DB is already exists.') {
super(description);
this.name = 'TenantDBAlreadyExists';
}
}
}

View File

@@ -0,0 +1,6 @@
export class TenantDatabaseNotBuilt extends Error {
constructor(description: string = 'Tenant database is not built yet.') {
super(description);
this.name = 'TenantDatabaseNotBuilt';
}
}

View File

@@ -15,5 +15,9 @@
"./src/modules/FinancialStatements/modules/BalanceSheet/**.ts",
"./src/modules/FinancialStatements/modules/ProfitLossSheet/**.ts",
"./src/modules/Views",
"./src/modules/TenantDBManager",
"./src/modules/TenantDBManager/**.ts",
"./src/modules/Organization",
"./src/modules/Organization/**.ts"
]
}

33
pnpm-lock.yaml generated
View File

@@ -484,6 +484,12 @@ importers:
packages/server-nest:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.576.0
version: 3.583.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.583.0
version: 3.583.0
'@bigcapital/email-components':
specifier: '*'
version: link:../../shared/email-components
@@ -496,6 +502,9 @@ importers:
'@bigcapital/utils':
specifier: '*'
version: link:../../shared/bigcapital-utils
'@casl/ability':
specifier: ^5.4.3
version: 5.4.4
'@lemonsqueezy/lemonsqueezy.js':
specifier: ^2.2.0
version: 2.2.0
@@ -613,12 +622,21 @@ importers:
lodash:
specifier: ^4.17.21
version: 4.17.21
lru-cache:
specifier: ^6.0.0
version: 6.0.0
mathjs:
specifier: ^9.4.0
version: 9.5.2
moment:
specifier: ^2.30.1
version: 2.30.1
moment-range:
specifier: ^4.0.2
version: 4.0.2(moment@2.30.1)
moment-timezone:
specifier: ^0.5.43
version: 0.5.45
mysql:
specifier: ^2.18.1
version: 2.18.1
@@ -1516,7 +1534,7 @@ packages:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
'@aws-sdk/client-sso-oidc': 3.583.0(@aws-sdk/client-sts@3.583.0)
'@aws-sdk/client-sts': 3.583.0
'@aws-sdk/client-sts': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)
'@aws-sdk/core': 3.582.0
'@aws-sdk/credential-provider-node': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)(@aws-sdk/client-sts@3.583.0)
'@aws-sdk/middleware-host-header': 3.577.0
@@ -1567,7 +1585,7 @@ packages:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
'@aws-sdk/client-sso-oidc': 3.583.0(@aws-sdk/client-sts@3.583.0)
'@aws-sdk/client-sts': 3.583.0
'@aws-sdk/client-sts': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)
'@aws-sdk/core': 3.582.0
'@aws-sdk/credential-provider-node': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)(@aws-sdk/client-sts@3.583.0)
'@aws-sdk/middleware-bucket-endpoint': 3.577.0
@@ -1631,7 +1649,7 @@ packages:
dependencies:
'@aws-crypto/sha256-browser': 3.0.0
'@aws-crypto/sha256-js': 3.0.0
'@aws-sdk/client-sts': 3.583.0
'@aws-sdk/client-sts': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)
'@aws-sdk/core': 3.582.0
'@aws-sdk/credential-provider-node': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)(@aws-sdk/client-sts@3.583.0)
'@aws-sdk/middleware-host-header': 3.577.0
@@ -1720,7 +1738,7 @@ packages:
- aws-crt
dev: false
/@aws-sdk/client-sts@3.583.0:
/@aws-sdk/client-sts@3.583.0(@aws-sdk/client-sso-oidc@3.583.0):
resolution: {integrity: sha512-xDMxiemPDWr9dY2Q4AyixkRnk/hvS6fs6OWxuVCz1WO47YhaAfOsEGAgQMgDLLaOfj/oLU5D14uTNBEPGh4rBA==}
engines: {node: '>=16.0.0'}
dependencies:
@@ -1765,6 +1783,7 @@ packages:
'@smithy/util-utf8': 3.0.0
tslib: 2.8.0
transitivePeerDependencies:
- '@aws-sdk/client-sso-oidc'
- aws-crt
dev: false
@@ -1827,7 +1846,7 @@ packages:
peerDependencies:
'@aws-sdk/client-sts': ^3.583.0
dependencies:
'@aws-sdk/client-sts': 3.583.0
'@aws-sdk/client-sts': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)
'@aws-sdk/credential-provider-env': 3.577.0
'@aws-sdk/credential-provider-process': 3.577.0
'@aws-sdk/credential-provider-sso': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)
@@ -1898,7 +1917,7 @@ packages:
peerDependencies:
'@aws-sdk/client-sts': ^3.577.0
dependencies:
'@aws-sdk/client-sts': 3.583.0
'@aws-sdk/client-sts': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)
'@aws-sdk/types': 3.577.0
'@smithy/property-provider': 3.0.0
'@smithy/types': 3.0.0
@@ -1912,7 +1931,7 @@ packages:
dependencies:
'@aws-sdk/client-cognito-identity': 3.583.0
'@aws-sdk/client-sso': 3.583.0
'@aws-sdk/client-sts': 3.583.0
'@aws-sdk/client-sts': 3.583.0(@aws-sdk/client-sso-oidc@3.583.0)
'@aws-sdk/credential-provider-cognito-identity': 3.583.0
'@aws-sdk/credential-provider-env': 3.577.0
'@aws-sdk/credential-provider-http': 3.582.0