refactor: organization service to nestjs

This commit is contained in:
Ahmed Bouhuolia
2025-03-25 04:34:22 +02:00
parent ef22b9ddaf
commit 92d98ce1d3
31 changed files with 1006 additions and 423 deletions

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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'
};

View File

@@ -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.',
});
}
}

View File

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

View File

@@ -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;
}

View File

@@ -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'),
};
};

View File

@@ -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.
*/

View File

@@ -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,
});
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View File

@@ -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,
},
);
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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[] = [
{

View File

@@ -10,4 +10,8 @@ export interface ISettingsDTO {
}
export const SETTINGS_PROVIDER = 'SETTINGS';
export const SETTINGS_PROVIDER = 'SETTINGS';
export enum PreferencesAction {
Mutate = 'Mutate'
}

View File

@@ -33,6 +33,7 @@ const models = [InjectSystemModel(PaymentIntegration)];
StripeWebhooksSubscriber,
TenancyContext,
],
exports: [StripePaymentService],
controllers: [StripeIntegrationController],
})
export class StripePaymentModule {}

View File

@@ -41,7 +41,7 @@ export class SubscriptionApplication {
* @param variantId
* @returns
*/
getLemonSqueezyCheckaoutUri(variantId: number) {
getLemonSqueezyCheckoutUri(variantId: number) {
return this.getLemonSqueezyCheckoutService.getCheckout(variantId);
}

View File

@@ -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.',

View File

@@ -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;

View File

@@ -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');
}
}

View File

@@ -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' });
}
}

View File

@@ -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,
);
}

View File

@@ -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.

View File

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

View File

@@ -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();
}
}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,6 @@
export class TenantDBAlreadyExists {
constructor() {
}
}

View 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, '');
}