mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 21:30:31 +00:00
refactor: organization service to nestjs
This commit is contained in:
@@ -65,6 +65,8 @@
|
||||
"lodash": "^4.17.21",
|
||||
"mathjs": "^9.4.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-range": "^4.0.2",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"mysql": "^2.18.1",
|
||||
"mysql2": "^3.11.3",
|
||||
"nestjs-cls": "^5.2.0",
|
||||
|
||||
@@ -76,6 +76,8 @@ import { DashboardModule } from '../Dashboard/Dashboard.module';
|
||||
import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module';
|
||||
import { RolesModule } from '../Roles/Roles.module';
|
||||
import { SubscriptionModule } from '../Subscription/Subscription.module';
|
||||
import { OrganizationModule } from '../Organization/Organization.module';
|
||||
import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -186,7 +188,9 @@ import { SubscriptionModule } from '../Subscription/Subscription.module';
|
||||
DashboardModule,
|
||||
PaymentLinksModule,
|
||||
RolesModule,
|
||||
SubscriptionModule
|
||||
SubscriptionModule,
|
||||
OrganizationModule,
|
||||
TenantDBManagerModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import currencies from 'js-money/lib/currency';
|
||||
|
||||
export const DATE_FORMATS = [
|
||||
'MM.dd.yy',
|
||||
'dd.MM.yy',
|
||||
'yy.MM.dd',
|
||||
'MM.dd.yyyy',
|
||||
'dd.MM.yyyy',
|
||||
'yyyy.MM.dd',
|
||||
'MM/DD/YYYY',
|
||||
'M/D/YYYY',
|
||||
'dd MMM YYYY',
|
||||
'dd MMMM YYYY',
|
||||
'MMMM dd, YYYY',
|
||||
'EEE, MMMM dd, YYYY',
|
||||
];
|
||||
export const MONTHS = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
];
|
||||
|
||||
export const ACCEPTED_LOCALES = ['en', 'ar'];
|
||||
|
||||
export const ERRORS = {
|
||||
TENANT_DATABASE_UPGRADED: 'TENANT_DATABASE_UPGRADED',
|
||||
TENANT_NOT_FOUND: 'tenant_not_found',
|
||||
TENANT_ALREADY_BUILT: 'TENANT_ALREADY_BUILT',
|
||||
TENANT_ALREADY_SEEDED: 'tenant_already_seeded',
|
||||
TENANT_DB_NOT_BUILT: 'tenant_db_not_built',
|
||||
TENANT_IS_BUILDING: 'TENANT_IS_BUILDING',
|
||||
BASE_CURRENCY_MUTATE_LOCKED: 'BASE_CURRENCY_MUTATE_LOCKED',
|
||||
TENANT_UPGRADE_IS_RUNNING: 'TENANT_UPGRADE_IS_RUNNING'
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Put,
|
||||
Get,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
Next,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { BuildOrganizationService } from './commands/BuildOrganization.service';
|
||||
import {
|
||||
BuildOrganizationDto,
|
||||
UpdateOrganizationDto,
|
||||
} from './dtos/Organization.dto';
|
||||
import { GetCurrentOrganizationService } from './queries/GetCurrentOrganization.service';
|
||||
import { UpdateOrganizationService } from './commands/UpdateOrganization.service';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Organization')
|
||||
@Controller('organization')
|
||||
export class OrganizationController {
|
||||
constructor(
|
||||
private readonly buildOrganizationService: BuildOrganizationService,
|
||||
private readonly getCurrentOrgService: GetCurrentOrganizationService,
|
||||
private readonly updateOrganizationService: UpdateOrganizationService,
|
||||
) {}
|
||||
|
||||
@Post('build')
|
||||
@ApiOperation({ summary: 'Build organization database' })
|
||||
@ApiBody({ type: BuildOrganizationDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The organization database has been initialized',
|
||||
})
|
||||
async build(
|
||||
@Body() buildDTO: BuildOrganizationDto,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const result = await this.buildOrganizationService.buildRunJob(buildDTO);
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'ORGANIZATION.DATABASE.INITIALIZED',
|
||||
message: 'The organization database has been initialized.',
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get current organization' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Returns the current organization',
|
||||
})
|
||||
async currentOrganization(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
const organization =
|
||||
await this.getCurrentOrgService.getCurrentOrganization();
|
||||
|
||||
return res.status(200).send({ organization });
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({ summary: 'Update organization information' })
|
||||
@ApiBody({ type: UpdateOrganizationDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Organization information has been updated successfully',
|
||||
})
|
||||
async updateOrganization(
|
||||
@Body() updateDTO: UpdateOrganizationDto,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
await this.updateOrganizationService.execute(updateDTO);
|
||||
|
||||
return res.status(200).send({
|
||||
message: 'Organization information has been updated successfully.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GetCurrentOrganizationService } from './queries/GetCurrentOrganization.service';
|
||||
import { BuildOrganizationService } from './commands/BuildOrganization.service';
|
||||
import { UpdateOrganizationService } from './commands/UpdateOrganization.service';
|
||||
import { OrganizationController } from './Organization.controller';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { OrganizationBuildQueue } from './Organization.types';
|
||||
import { OrganizationBuildProcessor } from './processors/OrganizationBuild.processor';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
GetCurrentOrganizationService,
|
||||
BuildOrganizationService,
|
||||
UpdateOrganizationService,
|
||||
OrganizationBuildProcessor
|
||||
],
|
||||
imports: [BullModule.registerQueue({ name: OrganizationBuildQueue })],
|
||||
controllers: [OrganizationController],
|
||||
})
|
||||
export class OrganizationModule {}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SystemUser } from '../System/models/SystemUser';
|
||||
|
||||
export interface IOrganizationSetupDTO {
|
||||
organizationName: string;
|
||||
baseCurrency: string;
|
||||
fiscalYear: string;
|
||||
industry: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export interface IOrganizationBuildDTO {
|
||||
name: string;
|
||||
industry: string;
|
||||
location: string;
|
||||
baseCurrency: string;
|
||||
timezone: string;
|
||||
fiscalYear: string;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
interface OrganizationAddressDTO {
|
||||
address1: string;
|
||||
address2: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
stateProvince: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface IOrganizationUpdateDTO {
|
||||
name: string;
|
||||
location?: string;
|
||||
baseCurrency?: string;
|
||||
timezone?: string;
|
||||
fiscalYear?: string;
|
||||
industry?: string;
|
||||
taxNumber?: string;
|
||||
primaryColor?: string;
|
||||
logoKey?: string;
|
||||
address?: OrganizationAddressDTO;
|
||||
}
|
||||
|
||||
export interface IOrganizationBuildEventPayload {
|
||||
tenantId: number;
|
||||
buildDTO: IOrganizationBuildDTO;
|
||||
systemUser: SystemUser;
|
||||
}
|
||||
|
||||
export interface IOrganizationBuiltEventPayload {
|
||||
tenantId: number;
|
||||
}
|
||||
|
||||
export const OrganizationBuildQueue = 'OrganizationBuildQueue';
|
||||
export const OrganizationBuildQueueJob = 'OrganizationBuildQueueJob';
|
||||
|
||||
export interface OrganizationBuildQueueJobPayload {
|
||||
buildDto: IOrganizationBuildDTO;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defaultTo } from 'lodash';
|
||||
import { IOrganizationBuildDTO } from './Organization.types';
|
||||
|
||||
/**
|
||||
* Transformes build DTO object.
|
||||
* @param {IOrganizationBuildDTO} buildDTO
|
||||
* @returns {IOrganizationBuildDTO}
|
||||
*/
|
||||
export const transformBuildDto = (
|
||||
buildDTO: IOrganizationBuildDTO,
|
||||
): IOrganizationBuildDTO => {
|
||||
return {
|
||||
...buildDTO,
|
||||
dateFormat: defaultTo(buildDTO.dateFormat, 'DD MMM yyyy'),
|
||||
};
|
||||
};
|
||||
@@ -1,18 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { TimeoutSettings } from 'puppeteer';
|
||||
|
||||
|
||||
interface MutateBaseCurrencyLockMeta {
|
||||
modelName: string;
|
||||
pluralName?: string;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export default class OrganizationBaseCurrencyLocking {
|
||||
@Inject()
|
||||
tenancy: HasTenancyService;
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationBaseCurrencyLocking {
|
||||
/**
|
||||
* Retrieves the tenant models that have prevented mutation base currency.
|
||||
*/
|
||||
@@ -1,338 +0,0 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { defaultTo, pick } from 'lodash';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import {
|
||||
IOrganizationBuildDTO,
|
||||
IOrganizationBuildEventPayload,
|
||||
IOrganizationBuiltEventPayload,
|
||||
IOrganizationUpdateDTO,
|
||||
ISystemUser,
|
||||
ITenant,
|
||||
} from '@/interfaces';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
import config from '../../config';
|
||||
import TenantsManager from '@/services/Tenancy/TenantsManager';
|
||||
import { Tenant } from '@/system/models';
|
||||
import OrganizationBaseCurrencyLocking from './OrganizationBaseCurrencyLocking';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { ERRORS } from './constants';
|
||||
import { initializeTenantSettings } from '@/api/middleware/SettingsMiddleware';
|
||||
import { initalizeTenantServices } from '@/api/middleware/TenantDependencyInjection';
|
||||
|
||||
@Service()
|
||||
export default class OrganizationService {
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
@Inject()
|
||||
private tenantsManager: TenantsManager;
|
||||
|
||||
@Inject('agenda')
|
||||
private agenda: any;
|
||||
|
||||
@Inject()
|
||||
private baseCurrencyMutateLocking: OrganizationBaseCurrencyLocking;
|
||||
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
/**
|
||||
* Builds the database schema and seed data of the given organization id.
|
||||
* @param {srting} organizationId
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async build(
|
||||
tenantId: number,
|
||||
buildDTO: IOrganizationBuildDTO,
|
||||
systemUser: ISystemUser
|
||||
): Promise<void> {
|
||||
const tenant = await this.getTenantOrThrowError(tenantId);
|
||||
|
||||
// Throw error if the tenant is already initialized.
|
||||
this.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');
|
||||
|
||||
// Injects the given tenant IoC services.
|
||||
await initalizeTenantServices(tenantId);
|
||||
await initializeTenantSettings(tenantId);
|
||||
|
||||
// Creates a tenancy object from given tenant model.
|
||||
const tenancyContext =
|
||||
this.tenantsManager.getSeedMigrationContext(migratedTenant);
|
||||
|
||||
// Seed tenant.
|
||||
await this.tenantsManager.seedTenant(migratedTenant, tenancyContext);
|
||||
|
||||
// Throws `onOrganizationBuild` event.
|
||||
await this.eventPublisher.emitAsync(events.organization.build, {
|
||||
tenantId: tenant.id,
|
||||
buildDTO,
|
||||
systemUser,
|
||||
} as IOrganizationBuildEventPayload);
|
||||
|
||||
// Markes the tenant as completed builing.
|
||||
await Tenant.markAsBuilt(tenantId);
|
||||
await Tenant.markAsBuildCompleted(tenantId);
|
||||
|
||||
//
|
||||
await this.flagTenantDBBatch(tenantId);
|
||||
|
||||
// Triggers the organization built event.
|
||||
await this.eventPublisher.emitAsync(events.organization.built, {
|
||||
tenantId: tenant.id,
|
||||
} as IOrganizationBuiltEventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} tenantId
|
||||
* @param {IOrganizationBuildDTO} buildDTO
|
||||
* @returns
|
||||
*/
|
||||
async buildRunJob(
|
||||
tenantId: number,
|
||||
buildDTO: IOrganizationBuildDTO,
|
||||
authorizedUser: ISystemUser
|
||||
) {
|
||||
const tenant = await this.getTenantOrThrowError(tenantId);
|
||||
|
||||
// Throw error if the tenant is already initialized.
|
||||
this.throwIfTenantInitizalized(tenant);
|
||||
|
||||
// Throw error if tenant is currently building.
|
||||
this.throwIfTenantIsBuilding(tenant);
|
||||
|
||||
// Transformes build DTO object.
|
||||
const transformedBuildDTO = this.transformBuildDTO(buildDTO);
|
||||
|
||||
// Saves the tenant metadata.
|
||||
await tenant.saveMetadata(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);
|
||||
|
||||
return {
|
||||
nextRunAt: jobMeta.attrs.nextRunAt,
|
||||
jobId: jobMeta.attrs._id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks tenant build run job.
|
||||
* @param {number} tenantId
|
||||
* @param {number} jobId
|
||||
*/
|
||||
public async revertBuildRunJob(tenantId: number, jobId: string) {
|
||||
await Tenant.markAsBuildCompleted(tenantId, jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the current organization metadata.
|
||||
* @param {number} tenantId
|
||||
* @returns {Promise<ITenant[]>}
|
||||
*/
|
||||
public async currentOrganization(tenantId: number): Promise<ITenant> {
|
||||
const tenant = await Tenant.query()
|
||||
.findById(tenantId)
|
||||
.withGraphFetched('subscriptions')
|
||||
.withGraphFetched('metadata');
|
||||
|
||||
this.throwIfTenantNotExists(tenant);
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve organization ability of mutate base currency
|
||||
* @param {number} tenantId
|
||||
* @returns
|
||||
*/
|
||||
public mutateBaseCurrencyAbility(tenantId: number) {
|
||||
return this.baseCurrencyMutateLocking.baseCurrencyMutateLocks(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates organization information.
|
||||
* @param {ITenant} tenantId
|
||||
* @param {IOrganizationUpdateDTO} organizationDTO
|
||||
*/
|
||||
public async updateOrganization(
|
||||
tenantId: number,
|
||||
organizationDTO: IOrganizationUpdateDTO
|
||||
): Promise<void> {
|
||||
const tenant = await Tenant.query()
|
||||
.findById(tenantId)
|
||||
.withGraphFetched('metadata');
|
||||
|
||||
// Throw error if the tenant not exists.
|
||||
this.throwIfTenantNotExists(tenant);
|
||||
|
||||
// Validate organization transactions before mutate base currency.
|
||||
if (organizationDTO.baseCurrency) {
|
||||
await this.validateMutateBaseCurrency(
|
||||
tenant,
|
||||
organizationDTO.baseCurrency,
|
||||
tenant.metadata?.baseCurrency
|
||||
);
|
||||
}
|
||||
await tenant.saveMetadata(organizationDTO);
|
||||
|
||||
if (organizationDTO.baseCurrency !== tenant.metadata?.baseCurrency) {
|
||||
// Triggers `onOrganizationBaseCurrencyUpdated` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.organization.baseCurrencyUpdated,
|
||||
{
|
||||
tenantId,
|
||||
organizationDTO,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformes build DTO object.
|
||||
* @param {IOrganizationBuildDTO} buildDTO
|
||||
* @returns {IOrganizationBuildDTO}
|
||||
*/
|
||||
private transformBuildDTO(
|
||||
buildDTO: IOrganizationBuildDTO
|
||||
): IOrganizationBuildDTO {
|
||||
return {
|
||||
...buildDTO,
|
||||
dateFormat: defaultTo(buildDTO.dateFormat, 'DD MMM yyyy'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw base currency mutate locked error.
|
||||
*/
|
||||
private throwBaseCurrencyMutateLocked() {
|
||||
throw new ServiceError(ERRORS.BASE_CURRENCY_MUTATE_LOCKED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mutate base currency ability.
|
||||
* @param {Tenant} tenant -
|
||||
* @param {string} newBaseCurrency -
|
||||
* @param {string} oldBaseCurrency -
|
||||
*/
|
||||
private async validateMutateBaseCurrency(
|
||||
tenant: Tenant,
|
||||
newBaseCurrency: string,
|
||||
oldBaseCurrency: string
|
||||
) {
|
||||
if (tenant.isReady && newBaseCurrency !== oldBaseCurrency) {
|
||||
const isLocked =
|
||||
await this.baseCurrencyMutateLocking.isBaseCurrencyMutateLocked(
|
||||
tenant.id
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
this.throwBaseCurrencyMutateLocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws error in case the given tenant is undefined.
|
||||
* @param {ITenant} tenant
|
||||
*/
|
||||
private throwIfTenantNotExists(tenant: ITenant) {
|
||||
if (!tenant) {
|
||||
throw new ServiceError(ERRORS.TENANT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws error in case the given tenant is already initialized.
|
||||
* @param {ITenant} tenant
|
||||
*/
|
||||
private throwIfTenantInitizalized(tenant: ITenant) {
|
||||
if (tenant.builtAt) {
|
||||
throw new ServiceError(ERRORS.TENANT_ALREADY_BUILT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw error if the tenant is building.
|
||||
* @param {ITenant} tenant
|
||||
*/
|
||||
private throwIfTenantIsBuilding(tenant) {
|
||||
if (tenant.buildJobId) {
|
||||
throw new ServiceError(ERRORS.TENANT_IS_BUILDING);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve tenant of throw not found error.
|
||||
* @param {number} tenantId -
|
||||
*/
|
||||
async getTenantOrThrowError(tenantId: number): Promise<ITenant> {
|
||||
const tenant = await Tenant.query().findById(tenantId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new ServiceError(ERRORS.TENANT_NOT_FOUND);
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs system user to tenant user.
|
||||
*/
|
||||
public async syncSystemUserToTenant(
|
||||
tenantId: number,
|
||||
systemUser: ISystemUser
|
||||
) {
|
||||
const { User, Role } = this.tenancy.models(tenantId);
|
||||
|
||||
const adminRole = await Role.query().findOne('slug', 'admin');
|
||||
|
||||
await User.query().insert({
|
||||
...pick(systemUser, [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'phoneNumber',
|
||||
'email',
|
||||
'active',
|
||||
'inviteAcceptedAt',
|
||||
]),
|
||||
systemUserId: systemUser.id,
|
||||
roleId: adminRole.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from './constants';
|
||||
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||
|
||||
/**
|
||||
* Throw base currency mutate locked error.
|
||||
*/
|
||||
export function throwBaseCurrencyMutateLocked() {
|
||||
throw new ServiceError(ERRORS.BASE_CURRENCY_MUTATE_LOCKED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws error in case the given tenant is undefined.
|
||||
* @param {TenantModel} tenant
|
||||
*/
|
||||
export function throwIfTenantNotExists(tenant: TenantModel) {
|
||||
if (!tenant) {
|
||||
throw new ServiceError(ERRORS.TENANT_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws error in case the given tenant is already initialized.
|
||||
* @param {TenantModel} tenant
|
||||
*/
|
||||
export function throwIfTenantInitizalized(tenant: TenantModel) {
|
||||
if (tenant.builtAt) {
|
||||
throw new ServiceError(ERRORS.TENANT_ALREADY_BUILT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw error if the tenant is building.
|
||||
* @param {TenantModel} tenant
|
||||
*/
|
||||
export function throwIfTenantIsBuilding(tenant: TenantModel) {
|
||||
if (tenant.buildJobId) {
|
||||
throw new ServiceError(ERRORS.TENANT_IS_BUILDING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
IOrganizationBuildDTO,
|
||||
IOrganizationBuildEventPayload,
|
||||
IOrganizationBuiltEventPayload,
|
||||
} from '../Organization.types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class BuildOrganizationService {
|
||||
constructor(
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
private readonly tenantsManager: TenantsManagerService,
|
||||
private readonly tenancyContext: TenancyContext
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Builds the database schema and seed data of the given organization id.
|
||||
* @param {srting} organizationId
|
||||
* @return {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);
|
||||
|
||||
// Seed tenant.
|
||||
await this.tenantsManager.seedTenant(migratedTenant, tenancyContext);
|
||||
|
||||
// Throws `onOrganizationBuild` event.
|
||||
await this.eventPublisher.emitAsync(events.organization.build, {
|
||||
tenantId: tenant.id,
|
||||
buildDTO,
|
||||
systemUser,
|
||||
} as IOrganizationBuildEventPayload);
|
||||
|
||||
// Markes the tenant as completed builing.
|
||||
await Tenant.markAsBuilt(tenantId);
|
||||
await Tenant.markAsBuildCompleted(tenantId);
|
||||
|
||||
//
|
||||
await this.flagTenantDBBatch(tenantId);
|
||||
|
||||
// Triggers the organization built event.
|
||||
await this.eventPublisher.emitAsync(events.organization.built, {
|
||||
tenantId: tenant.id,
|
||||
} as IOrganizationBuiltEventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} tenantId
|
||||
* @param {IOrganizationBuildDTO} buildDTO
|
||||
* @returns
|
||||
*/
|
||||
async buildRunJob(
|
||||
buildDTO: BuildOrganizationDto,
|
||||
) {
|
||||
const tenant = await this.tenancyContext.getTenant();
|
||||
const systemUser = await this.tenancyContext.getSystemUser();
|
||||
|
||||
// Throw error if the tenant is already initialized.
|
||||
throwIfTenantInitizalized(tenant);
|
||||
|
||||
// Throw error if tenant is currently building.
|
||||
throwIfTenantIsBuilding(tenant);
|
||||
|
||||
// Transformes build DTO object.
|
||||
const transformedBuildDTO = transformBuildDto(buildDTO);
|
||||
|
||||
// Saves the tenant metadata.
|
||||
await tenant.saveMetadata(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);
|
||||
|
||||
return {
|
||||
nextRunAt: jobMeta.attrs.nextRunAt,
|
||||
jobId: jobMeta.attrs._id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks tenant build run job.
|
||||
* @param {number} tenantId
|
||||
* @param {number} jobId
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TenancyContext } from "@/modules/Tenancy/TenancyContext.service";
|
||||
import { UpdateOrganizationDto } from "../dtos/Organization.dto";
|
||||
import { throwIfTenantNotExists } from "../Organization/_utils";
|
||||
|
||||
|
||||
export class UpdateOrganizationService {
|
||||
constructor(
|
||||
private readonly tenancyContext: TenancyContext
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates organization information.
|
||||
* @param {ITenant} tenantId
|
||||
* @param {IOrganizationUpdateDTO} organizationDTO
|
||||
*/
|
||||
public async execute(
|
||||
organizationDTO: UpdateOrganizationDto,
|
||||
): Promise<void> {
|
||||
const tenant = await this.tenancyContext.getTenant(true);
|
||||
|
||||
// Throw error if the tenant not exists.
|
||||
throwIfTenantNotExists(tenant);
|
||||
|
||||
// Validate organization transactions before mutate base currency.
|
||||
if (organizationDTO.baseCurrency) {
|
||||
await this.validateMutateBaseCurrency(
|
||||
tenant,
|
||||
organizationDTO.baseCurrency,
|
||||
tenant.metadata?.baseCurrency,
|
||||
);
|
||||
}
|
||||
await tenant.saveMetadata(organizationDTO);
|
||||
|
||||
if (organizationDTO.baseCurrency !== tenant.metadata?.baseCurrency) {
|
||||
// Triggers `onOrganizationBaseCurrencyUpdated` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.organization.baseCurrencyUpdated,
|
||||
{
|
||||
tenantId,
|
||||
organizationDTO,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import moment from 'moment';
|
||||
import {IsHexColor,
|
||||
IsIn,
|
||||
IsISO31661Alpha2,
|
||||
IsISO4217CurrencyCode,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { MONTHS } from '../Organization/constants';
|
||||
import { ACCEPTED_LOCALES, DATE_FORMATS } from '../Organization.constants';
|
||||
|
||||
export class BuildOrganizationDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
industry?: string;
|
||||
|
||||
@IsISO31661Alpha2()
|
||||
location: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
baseCurrency: string;
|
||||
|
||||
@IsIn(moment.tz.names())
|
||||
timezone: string;
|
||||
|
||||
@IsIn(MONTHS)
|
||||
fiscalYear: string;
|
||||
|
||||
@IsIn(ACCEPTED_LOCALES)
|
||||
language: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DATE_FORMATS)
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export class UpdateOrganizationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
industry?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO31661Alpha2()
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO4217CurrencyCode()
|
||||
baseCurrency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(moment.tz.names())
|
||||
timezone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(MONTHS)
|
||||
fiscalYear?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACCEPTED_LOCALES)
|
||||
language?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DATE_FORMATS)
|
||||
dateFormat?: string;
|
||||
|
||||
@IsOptional()
|
||||
address?: {
|
||||
address_1?: string;
|
||||
address_2?: string;
|
||||
postal_code?: string;
|
||||
city?: string;
|
||||
stateProvince?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
primaryColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
taxNumber?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Scope } from '@nestjs/common';
|
||||
import { Process } from '@nestjs/bull';
|
||||
import { Job } from 'bullmq';
|
||||
import {
|
||||
OrganizationBuildQueue,
|
||||
OrganizationBuildQueueJob,
|
||||
OrganizationBuildQueueJobPayload,
|
||||
} from '../Organization.types';
|
||||
import { BuildOrganizationService } from '../commands/BuildOrganization.service';
|
||||
|
||||
@Processor({
|
||||
name: OrganizationBuildQueue,
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class OrganizationBuildProcessor extends WorkerHost {
|
||||
constructor(
|
||||
private readonly organizationBuildService: BuildOrganizationService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Process(OrganizationBuildQueueJob)
|
||||
async process(job: Job<OrganizationBuildQueueJobPayload>) {
|
||||
try {
|
||||
await this.organizationBuildService.build(job.data.buildDto);
|
||||
} catch (e) {
|
||||
// Unlock build status of the tenant.
|
||||
await this.organizationBuildService.revertBuildRunJob();
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||
import { throwIfTenantNotExists } from '../Organization/_utils';
|
||||
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class GetCurrentOrganizationService {
|
||||
constructor(private readonly tenancyContext: TenancyContext) {}
|
||||
|
||||
/**
|
||||
* Retrieve the current organization metadata.
|
||||
* @param {number} tenantId
|
||||
* @returns {Promise<ITenant[]>}
|
||||
*/
|
||||
async getCurrentOrganization(): Promise<TenantModel> {
|
||||
const tenant = await this.tenancyContext
|
||||
.getTenant()
|
||||
.withGraphFetched('subscriptions')
|
||||
.withGraphFetched('metadata');
|
||||
|
||||
throwIfTenantNotExists(tenant);
|
||||
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { PaymentLinksApplication } from './PaymentLinksApplication';
|
||||
import { PaymentLinksController } from './PaymentLinks.controller';
|
||||
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
|
||||
import { PaymentLink } from './models/PaymentLink';
|
||||
import { StripePaymentModule } from '../StripePayment/StripePayment.module';
|
||||
|
||||
const models = [InjectSystemModel(PaymentLink)];
|
||||
|
||||
@Module({
|
||||
imports: [StripePaymentModule],
|
||||
providers: [
|
||||
...models,
|
||||
CreateInvoiceCheckoutSession,
|
||||
|
||||
@@ -13,7 +13,9 @@ import { SaleInvoiceAction } from "../SaleInvoices/SaleInvoice.types";
|
||||
import { CreditNoteAction } from "../CreditNotes/types/CreditNotes.types";
|
||||
import { SaleReceiptAction } from "../SaleReceipts/types/SaleReceipts.types";
|
||||
import { BillAction } from "../Bills/Bills.types";
|
||||
import { AbilitySubject, ISubjectAbilitiesSchema } from "./Roles.types";
|
||||
import { AbilitySubject, ISubjectAbilitiesSchema, ISubjectAbilitySchema } from "./Roles.types";
|
||||
import { PaymentReceiveAction } from "../PaymentReceived/types/PaymentReceived.types";
|
||||
import { PreferencesAction } from "../Settings/Settings.types";
|
||||
|
||||
export const AbilitySchema: ISubjectAbilitiesSchema[] = [
|
||||
{
|
||||
|
||||
@@ -10,4 +10,8 @@ export interface ISettingsDTO {
|
||||
}
|
||||
|
||||
|
||||
export const SETTINGS_PROVIDER = 'SETTINGS';
|
||||
export const SETTINGS_PROVIDER = 'SETTINGS';
|
||||
|
||||
export enum PreferencesAction {
|
||||
Mutate = 'Mutate'
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const models = [InjectSystemModel(PaymentIntegration)];
|
||||
StripeWebhooksSubscriber,
|
||||
TenancyContext,
|
||||
],
|
||||
exports: [StripePaymentService],
|
||||
controllers: [StripeIntegrationController],
|
||||
})
|
||||
export class StripePaymentModule {}
|
||||
|
||||
@@ -41,7 +41,7 @@ export class SubscriptionApplication {
|
||||
* @param variantId
|
||||
* @returns
|
||||
*/
|
||||
getLemonSqueezyCheckaoutUri(variantId: number) {
|
||||
getLemonSqueezyCheckoutUri(variantId: number) {
|
||||
return this.getLemonSqueezyCheckoutService.getCheckout(variantId);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,52 +6,28 @@ import {
|
||||
Req,
|
||||
Res,
|
||||
Next,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ApiOperation, ApiTags, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { SubscriptionApplication } from './SubscriptionApplication';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiResponse,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { Subscription } from './Subscription';
|
||||
import { LemonSqueezy } from './LemonSqueezy';
|
||||
|
||||
@Controller('subscription')
|
||||
@ApiTags('subscriptions')
|
||||
export class SubscriptionsController {
|
||||
constructor(
|
||||
private readonly subscriptionService: Subscription,
|
||||
private readonly lemonSqueezyService: LemonSqueezy,
|
||||
private readonly subscriptionApp: SubscriptionApplication,
|
||||
) {}
|
||||
constructor(private readonly subscriptionApp: SubscriptionApplication) {}
|
||||
|
||||
/**
|
||||
* Retrieve all subscriptions of the authenticated user's tenant.
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all subscriptions for the current tenant' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of subscriptions retrieved successfully',
|
||||
})
|
||||
async getSubscriptions(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
const tenantId = req.headers['organization-id'] as string;
|
||||
const subscriptions =
|
||||
await this.subscriptionService.getSubscriptions(tenantId);
|
||||
async getSubscriptions(@Res() res: Response) {
|
||||
const subscriptions = await this.subscriptionApp.getSubscriptions();
|
||||
|
||||
return res.status(200).send({ subscriptions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the LemonSqueezy checkout url.
|
||||
*/
|
||||
@Post('lemon/checkout_url')
|
||||
@ApiOperation({ summary: 'Get LemonSqueezy checkout URL' })
|
||||
@ApiBody({
|
||||
@@ -71,22 +47,15 @@ export class SubscriptionsController {
|
||||
description: 'Checkout URL retrieved successfully',
|
||||
})
|
||||
async getCheckoutUrl(
|
||||
@Body('variantId') variantId: string,
|
||||
@Req() req: Request,
|
||||
@Body('variantId') variantId: number,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
const user = req.user;
|
||||
const checkout = await this.lemonSqueezyService.getCheckout(
|
||||
variantId,
|
||||
user,
|
||||
);
|
||||
const checkout =
|
||||
await this.subscriptionApp.getLemonSqueezyCheckoutUri(variantId);
|
||||
|
||||
return res.status(200).send(checkout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the subscription of the current organization.
|
||||
*/
|
||||
@Post('cancel')
|
||||
@ApiOperation({ summary: 'Cancel the current organization subscription' })
|
||||
@ApiResponse({
|
||||
@@ -107,9 +76,6 @@ export class SubscriptionsController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the subscription of the current organization.
|
||||
*/
|
||||
@Post('resume')
|
||||
@ApiOperation({ summary: 'Resume the current organization subscription' })
|
||||
@ApiResponse({
|
||||
@@ -130,9 +96,6 @@ export class SubscriptionsController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the main subscription plan of the current organization.
|
||||
*/
|
||||
@Post('change')
|
||||
@ApiOperation({
|
||||
summary: 'Change the subscription plan of the current organization',
|
||||
@@ -155,12 +118,9 @@ export class SubscriptionsController {
|
||||
})
|
||||
async changeSubscriptionPlan(
|
||||
@Body('variant_id') variantId: number,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
const tenantId = req.headers['organization-id'] as string;
|
||||
await this.subscriptionApp.changeSubscriptionPlan(tenantId, variantId);
|
||||
await this.subscriptionApp.changeSubscriptionPlan(variantId);
|
||||
|
||||
return res.status(200).send({
|
||||
message: 'The subscription plan has been changed.',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SystemModel } from '@/modules/System/models/SystemModel';
|
||||
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;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Subscription } from '../Subscription';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { events } from '@/common/events/events';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SubscriptionApplication } from '../SubscriptionApplication';
|
||||
|
||||
@Injectable()
|
||||
export class SubscribeFreeOnSignupCommunity {
|
||||
constructor(
|
||||
private readonly subscriptionService: Subscription,
|
||||
private readonly subscriptionApp: SubscriptionApplication,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -21,9 +21,9 @@ export class SubscribeFreeOnSignupCommunity {
|
||||
signupDTO,
|
||||
tenant,
|
||||
user,
|
||||
}: IAuthSignedUpEventPayload) {
|
||||
}) {
|
||||
if (this.configService.get('hostedOnBigcapitalCloud')) return null;
|
||||
|
||||
await this.subscriptionService.newSubscribtion(tenant.id, 'free');
|
||||
await this.subscriptionApp.createNewSubscription('free');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ export class TriggerInvalidateCacheOnSubscriptionChange {
|
||||
@OnEvent(events.subscription.onSubscriptionResumed)
|
||||
@OnEvent(events.subscription.onSubscriptionPlanChanged)
|
||||
triggerInvalidateCache() {
|
||||
const io = Container.get('socket');
|
||||
// const io = Container.get('socket');
|
||||
|
||||
// Notify the frontend to reflect the new transactions changes.
|
||||
io.emit('SUBSCRIPTION_CHANGED', { subscriptionSlug: 'main' });
|
||||
// // Notify the frontend to reflect the new transactions changes.
|
||||
// io.emit('SUBSCRIPTION_CHANGED', { subscriptionSlug: 'main' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
compareSignatures,
|
||||
configureLemonSqueezy,
|
||||
@@ -6,14 +7,17 @@ import {
|
||||
webhookHasData,
|
||||
webhookHasMeta,
|
||||
} from '../utils';
|
||||
import { Subscription } from '../Subscription';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Plan } from '../models/Plan';
|
||||
import { SubscriptionApplication } from '../SubscriptionApplication';
|
||||
|
||||
@Injectable()
|
||||
export class LemonSqueezyWebhooks {
|
||||
constructor(
|
||||
private readonly subscriptionService: Subscription,
|
||||
private readonly subscriptionApp: SubscriptionApplication,
|
||||
private readonly configService: ConfigService,
|
||||
|
||||
@Inject(Plan.name)
|
||||
private readonly planModel: typeof Plan,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -68,14 +72,12 @@ export class LemonSqueezyWebhooks {
|
||||
if (webhookEvent.startsWith('subscription_payment_')) {
|
||||
// Marks the main subscription payment as succeed.
|
||||
if (webhookEvent === 'subscription_payment_success') {
|
||||
await this.subscriptionService.markSubscriptionPaymentSucceed(
|
||||
tenantId,
|
||||
await this.subscriptionApp.markSubscriptionPaymentSuccessed(
|
||||
subscriptionSlug,
|
||||
);
|
||||
// Marks the main subscription payment as failed.
|
||||
} else if (webhookEvent === 'subscription_payment_failed') {
|
||||
await this.subscriptionService.markSubscriptionPaymentFailed(
|
||||
tenantId,
|
||||
await this.subscriptionApp.markSubscriptionPaymentFailed(
|
||||
subscriptionSlug,
|
||||
);
|
||||
}
|
||||
@@ -87,7 +89,9 @@ export class LemonSqueezyWebhooks {
|
||||
const variantId = attributes.variant_id as string;
|
||||
|
||||
// We assume that the Plan table is up to date.
|
||||
const plan = await Plan.query().findOne('lemonVariantId', variantId);
|
||||
const plan = await this.planModel
|
||||
.query()
|
||||
.findOne('lemonVariantId', variantId);
|
||||
|
||||
// Update the subscription in the database.
|
||||
const priceId = attributes.first_subscription_item.price_id;
|
||||
@@ -99,27 +103,23 @@ export class LemonSqueezyWebhooks {
|
||||
}
|
||||
// Create a new subscription of the tenant.
|
||||
if (webhookEvent === 'subscription_created') {
|
||||
await this.subscriptionService.newSubscribtion(
|
||||
tenantId,
|
||||
await this.subscriptionApp.createNewSubscription(
|
||||
plan.slug,
|
||||
subscriptionSlug,
|
||||
{ lemonSqueezyId: subscriptionId },
|
||||
);
|
||||
// Cancel the given subscription of the organization.
|
||||
} else if (webhookEvent === 'subscription_cancelled') {
|
||||
await this.subscriptionService.cancelSubscription(
|
||||
tenantId,
|
||||
await this.subscriptionApp.cancelSubscription(
|
||||
subscriptionSlug,
|
||||
);
|
||||
} else if (webhookEvent === 'subscription_plan_changed') {
|
||||
await this.subscriptionService.subscriptionPlanChanged(
|
||||
tenantId,
|
||||
await this.subscriptionApp.markSubscriptionPlanChanged(
|
||||
plan.slug,
|
||||
subscriptionSlug,
|
||||
);
|
||||
} else if (webhookEvent === 'subscription_resumed') {
|
||||
await this.subscriptionService.resumeSubscription(
|
||||
tenantId,
|
||||
await this.subscriptionApp.resumeSubscription(
|
||||
subscriptionSlug,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export class TenantModel extends BaseModel {
|
||||
public readonly seededAt: boolean;
|
||||
public readonly builtAt: string;
|
||||
public readonly metadata: TenantMetadata;
|
||||
public readonly buildJobId: string;
|
||||
|
||||
/**
|
||||
* Table name.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class TenantDBManagerModule {}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Knex, knex } from 'knex';
|
||||
import { knexSnakeCaseMappers } from 'objection';
|
||||
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';
|
||||
|
||||
export class TenantDBManager {
|
||||
static knexCache: { [key: string]: Knex } = {};
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
|
||||
@Inject(SystemKnexConnection)
|
||||
private readonly systemKnex: Knex,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve the tenant database name.
|
||||
* @return {string}
|
||||
*/
|
||||
private getDatabaseName(tenant: ITenant) {
|
||||
return sanitizeDatabaseName(
|
||||
`${this.configService.get('tenant.db_name_prefix')}${tenant.organizationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detarmines the tenant database weather exists.
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
public async databaseExists(tenant: ITenant) {
|
||||
const databaseName = this.getDatabaseName(tenant);
|
||||
const results = await this.systemKnex.raw(
|
||||
'SELECT * FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = "' +
|
||||
databaseName +
|
||||
'"',
|
||||
);
|
||||
return results[0].length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tenant database.
|
||||
* @throws {TenantAlreadyInitialized}
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async createDatabase(tenant: ITenant): Promise<void> {
|
||||
await this.throwErrorIfTenantDBExists(tenant);
|
||||
|
||||
const databaseName = this.getDatabaseName(tenant);
|
||||
await this.systemKnex.raw(
|
||||
`CREATE DATABASE ${databaseName} DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdowns the tenant database if it was exist.
|
||||
* @param {ITenant} tenant -
|
||||
*/
|
||||
public async dropDatabaseIfExists(tenant: ITenant) {
|
||||
const isExists = await this.databaseExists(tenant);
|
||||
|
||||
if (!isExists) {
|
||||
return;
|
||||
}
|
||||
await this.dropDatabase(tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* dropdowns the tenant's database.
|
||||
* @param {ITenant} tenant
|
||||
*/
|
||||
public async dropDatabase(tenant: ITenant) {
|
||||
const databaseName = this.getDatabaseName(tenant);
|
||||
|
||||
await this.systemKnex.raw(`DROP DATABASE IF EXISTS ${databaseName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate tenant database schema to the latest version.
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async migrate(tenant: ITenant): Promise<void> {
|
||||
const knex = this.setupKnexInstance(tenant);
|
||||
await knex.migrate.latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds initial data to the tenant database.
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async seed(tenant: ITenant): Promise<void> {
|
||||
const knex = this.setupKnexInstance(tenant);
|
||||
|
||||
await knex.migrate.latest({
|
||||
...tenantSeedConfig(tenant),
|
||||
disableMigrationsListValidation: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the knex instance of tenant.
|
||||
* @return {Knex}
|
||||
*/
|
||||
private setupKnexInstance(tenant: ITenant) {
|
||||
const key: string = `${tenant.id}`;
|
||||
let knexInstance = TenantDBManager.knexCache[key];
|
||||
|
||||
if (!knexInstance) {
|
||||
knexInstance = knex({
|
||||
...tenantKnexConfig(tenant),
|
||||
...knexSnakeCaseMappers({ upperCase: true }),
|
||||
});
|
||||
TenantDBManager.knexCache[key] = knexInstance;
|
||||
}
|
||||
return knexInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve knex instance from the givne tenant.
|
||||
*/
|
||||
public getKnexInstance(tenantId: number) {
|
||||
const key: string = `${tenantId}`;
|
||||
let knexInstance = TenantDBManager.knexCache[key];
|
||||
|
||||
if (!knexInstance) {
|
||||
throw new Error('Knex instance is not initialized yut.');
|
||||
}
|
||||
return knexInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws error if the tenant database already exists.
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async throwErrorIfTenantDBExists(tenant: ITenant) {
|
||||
const isExists = await this.databaseExists(tenant);
|
||||
if (isExists) {
|
||||
throw new TenantDBAlreadyExists();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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',
|
||||
// };
|
||||
|
||||
@Injectable()
|
||||
export class TenantsManagerService {
|
||||
|
||||
constructor(
|
||||
private readonly tenantDbManager: TenantDBManager,
|
||||
private readonly eventEmitter: EventEmitter2
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new teant with unique organization id.
|
||||
* @param {ITenant} tenant
|
||||
* @return {Promise<ITenant>}
|
||||
*/
|
||||
public async createTenant(): Promise<ITenant> {
|
||||
const { tenantRepository } = this.sysRepositories;
|
||||
const tenant = await tenantRepository.createWithUniqueOrgId();
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tenant database.
|
||||
* @param {ITenant} tenant -
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async createDatabase(tenant: ITenant): Promise<void> {
|
||||
this.throwErrorIfTenantAlreadyInitialized(tenant);
|
||||
|
||||
await this.tenantDbManager.createDatabase(tenant);
|
||||
|
||||
await this.eventEmitter.emitAsync(events.tenantManager.databaseCreated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the database if the given tenant.
|
||||
* @param {number} tenantId
|
||||
*/
|
||||
async dropDatabaseIfExists(tenant: ITenant) {
|
||||
// Drop the database if exists.
|
||||
await this.tenantDbManager.dropDatabaseIfExists(tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detarmines the tenant has database.
|
||||
* @param {ITenant} tenant
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
public async hasDatabase(tenant: ITenant): Promise<boolean> {
|
||||
return this.tenantDbManager.databaseExists(tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the tenant database.
|
||||
* @param {ITenant} tenant
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async migrateTenant(tenant: ITenant): Promise<void> {
|
||||
// Throw error if the tenant already initialized.
|
||||
this.throwErrorIfTenantAlreadyInitialized(tenant);
|
||||
|
||||
// Migrate the database tenant.
|
||||
await this.tenantDbManager.migrate(tenant);
|
||||
|
||||
// Mark the tenant as initialized.
|
||||
await Tenant.markAsInitialized(tenant.id);
|
||||
|
||||
// Triggers `onTenantMigrated` event.
|
||||
this.eventDispatcher.dispatch(events.tenantManager.tenantMigrated, {
|
||||
tenantId: tenant.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the tenant database.
|
||||
* @param {ITenant} tenant
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async seedTenant(tenant: ITenant, tenancyContext): Promise<void> {
|
||||
// Throw error if the tenant is not built yet.
|
||||
this.throwErrorIfTenantNotBuilt(tenant);
|
||||
|
||||
// Throw error if the tenant is not seeded yet.
|
||||
this.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);
|
||||
|
||||
// Triggers `onTenantSeeded` event.
|
||||
this.eventDispatcher.dispatch(events.tenantManager.tenantSeeded, {
|
||||
tenantId: tenant.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize knex instance or retrieve the instance of cache map.
|
||||
* @param {ITenant} tenant
|
||||
* @returns {Knex}
|
||||
*/
|
||||
public setupKnexInstance(tenant: ITenant) {
|
||||
return this.tenantDBManager.setupKnexInstance(tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve tenant knex instance or throw error in case was not initialized.
|
||||
* @param {number} tenantId
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export class TenantDBAlreadyExists {
|
||||
constructor() {
|
||||
|
||||
}
|
||||
}
|
||||
4
packages/server-nest/src/utils/sanitize-database-name.ts
Normal file
4
packages/server-nest/src/utils/sanitize-database-name.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function sanitizeDatabaseName(dbName: string) {
|
||||
// Replace any character that is not alphanumeric or an underscore with an underscore
|
||||
return dbName.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
}
|
||||
Reference in New Issue
Block a user