From 92d98ce1d3a382373dae7c6cba7068b5c7248ef4 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 25 Mar 2025 04:34:22 +0200 Subject: [PATCH] refactor: organization service to nestjs --- packages/server-nest/package.json | 2 + .../server-nest/src/modules/App/App.module.ts | 6 +- .../Organization/Organization.constants.ts | 43 +++ .../Organization/Organization.controller.ts | 86 +++++ .../Organization/Organization.module.ts | 20 ++ .../Organization/Organization.types.ts | 58 +++ .../Organization/Organization.utils.ts | 16 + ...rganizationBaseCurrencyLocking.service.ts} | 12 +- .../Organization/OrganizationService.ts | 338 ------------------ .../Organization/Organization/_utils.ts | 40 +++ .../commands/BuildOrganization.service.ts | 138 +++++++ .../commands/UpdateOrganization.service.ts | 47 +++ .../Organization/dtos/Organization.dto.ts | 94 +++++ .../processors/OrganizationBuild.processor.ts | 33 ++ .../queries/GetCurrentOrganization.service.ts | 25 ++ .../PaymentLinks/PaymentLinks.module.ts | 2 + .../src/modules/Roles/AbilitySchema.ts | 4 +- .../src/modules/Settings/Settings.types.ts | 6 +- .../StripePayment/StripePayment.module.ts | 1 + .../Subscription/SubscriptionApplication.ts | 2 +- .../Subscription/Subscriptions.controller.ts | 58 +-- .../src/modules/Subscription/models/Plan.ts | 1 + .../SubscribeFreeOnSignupCommunity.ts | 8 +- ...ggerInvalidateCacheOnSubscriptionChange.ts | 6 +- .../webhooks/LemonSqueezyWebhooks.ts | 34 +- .../src/modules/System/models/TenantModel.ts | 1 + .../TenantDBManager/TenantDBManager.module.ts | 4 + .../TenantDBManager/TenantDBManager.ts | 143 ++++++++ .../modules/TenantDBManager/TenantsManager.ts | 191 ++++++++++ .../exceptions/TenantDBAlreadyExists.ts | 6 + .../src/utils/sanitize-database-name.ts | 4 + 31 files changed, 1006 insertions(+), 423 deletions(-) create mode 100644 packages/server-nest/src/modules/Organization/Organization.constants.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization.types.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization.utils.ts rename packages/server-nest/src/modules/Organization/Organization/{OrganizationBaseCurrencyLocking.ts => OrganizationBaseCurrencyLocking.service.ts} (89%) delete mode 100644 packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization/_utils.ts create mode 100644 packages/server-nest/src/modules/Organization/dtos/Organization.dto.ts create mode 100644 packages/server-nest/src/modules/Organization/processors/OrganizationBuild.processor.ts create mode 100644 packages/server-nest/src/modules/TenantDBManager/TenantDBManager.module.ts create mode 100644 packages/server-nest/src/modules/TenantDBManager/TenantDBManager.ts create mode 100644 packages/server-nest/src/modules/TenantDBManager/TenantsManager.ts create mode 100644 packages/server-nest/src/modules/TenantDBManager/exceptions/TenantDBAlreadyExists.ts create mode 100644 packages/server-nest/src/utils/sanitize-database-name.ts diff --git a/packages/server-nest/package.json b/packages/server-nest/package.json index 54bfe4ea8..1c1a8243b 100644 --- a/packages/server-nest/package.json +++ b/packages/server-nest/package.json @@ -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", diff --git a/packages/server-nest/src/modules/App/App.module.ts b/packages/server-nest/src/modules/App/App.module.ts index 0275a339b..303cd83a0 100644 --- a/packages/server-nest/src/modules/App/App.module.ts +++ b/packages/server-nest/src/modules/App/App.module.ts @@ -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: [ diff --git a/packages/server-nest/src/modules/Organization/Organization.constants.ts b/packages/server-nest/src/modules/Organization/Organization.constants.ts new file mode 100644 index 000000000..a1c0bd6f7 --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization.constants.ts @@ -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' +}; diff --git a/packages/server-nest/src/modules/Organization/Organization.controller.ts b/packages/server-nest/src/modules/Organization/Organization.controller.ts index e69de29bb..c219362b6 100644 --- a/packages/server-nest/src/modules/Organization/Organization.controller.ts +++ b/packages/server-nest/src/modules/Organization/Organization.controller.ts @@ -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.', + }); + } +} diff --git a/packages/server-nest/src/modules/Organization/Organization.module.ts b/packages/server-nest/src/modules/Organization/Organization.module.ts index e69de29bb..aab055ee1 100644 --- a/packages/server-nest/src/modules/Organization/Organization.module.ts +++ b/packages/server-nest/src/modules/Organization/Organization.module.ts @@ -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 {} diff --git a/packages/server-nest/src/modules/Organization/Organization.types.ts b/packages/server-nest/src/modules/Organization/Organization.types.ts new file mode 100644 index 000000000..718b75643 --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization.types.ts @@ -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; +} \ No newline at end of file diff --git a/packages/server-nest/src/modules/Organization/Organization.utils.ts b/packages/server-nest/src/modules/Organization/Organization.utils.ts new file mode 100644 index 000000000..9273bf0b1 --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization.utils.ts @@ -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'), + }; +}; diff --git a/packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.ts b/packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts similarity index 89% rename from packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.ts rename to packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts index ad3fd2783..512722eab 100644 --- a/packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.ts +++ b/packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts @@ -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. */ diff --git a/packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts b/packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts deleted file mode 100644 index 67c9656c8..000000000 --- a/packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts +++ /dev/null @@ -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} - */ - public async build( - tenantId: number, - buildDTO: IOrganizationBuildDTO, - systemUser: ISystemUser - ): Promise { - 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} - */ - public async currentOrganization(tenantId: number): Promise { - 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 { - 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 { - 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, - }); - } -} diff --git a/packages/server-nest/src/modules/Organization/Organization/_utils.ts b/packages/server-nest/src/modules/Organization/Organization/_utils.ts new file mode 100644 index 000000000..5f8e202c0 --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization/_utils.ts @@ -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); + } +} diff --git a/packages/server-nest/src/modules/Organization/commands/BuildOrganization.service.ts b/packages/server-nest/src/modules/Organization/commands/BuildOrganization.service.ts index e69de29bb..11c14d88d 100644 --- a/packages/server-nest/src/modules/Organization/commands/BuildOrganization.service.ts +++ b/packages/server-nest/src/modules/Organization/commands/BuildOrganization.service.ts @@ -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} + */ + public async build( + buildDTO: BuildOrganizationDto, + ): Promise { + 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 }); + } +} diff --git a/packages/server-nest/src/modules/Organization/commands/UpdateOrganization.service.ts b/packages/server-nest/src/modules/Organization/commands/UpdateOrganization.service.ts index e69de29bb..1f762ad82 100644 --- a/packages/server-nest/src/modules/Organization/commands/UpdateOrganization.service.ts +++ b/packages/server-nest/src/modules/Organization/commands/UpdateOrganization.service.ts @@ -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 { + 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, + }, + ); + } + } +} \ No newline at end of file diff --git a/packages/server-nest/src/modules/Organization/dtos/Organization.dto.ts b/packages/server-nest/src/modules/Organization/dtos/Organization.dto.ts new file mode 100644 index 000000000..81f5b920b --- /dev/null +++ b/packages/server-nest/src/modules/Organization/dtos/Organization.dto.ts @@ -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; +} diff --git a/packages/server-nest/src/modules/Organization/processors/OrganizationBuild.processor.ts b/packages/server-nest/src/modules/Organization/processors/OrganizationBuild.processor.ts new file mode 100644 index 000000000..15551dbcc --- /dev/null +++ b/packages/server-nest/src/modules/Organization/processors/OrganizationBuild.processor.ts @@ -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) { + try { + await this.organizationBuildService.build(job.data.buildDto); + } catch (e) { + // Unlock build status of the tenant. + await this.organizationBuildService.revertBuildRunJob(); + console.error(e); + } + } +} diff --git a/packages/server-nest/src/modules/Organization/queries/GetCurrentOrganization.service.ts b/packages/server-nest/src/modules/Organization/queries/GetCurrentOrganization.service.ts index e69de29bb..6c981a600 100644 --- a/packages/server-nest/src/modules/Organization/queries/GetCurrentOrganization.service.ts +++ b/packages/server-nest/src/modules/Organization/queries/GetCurrentOrganization.service.ts @@ -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} + */ + async getCurrentOrganization(): Promise { + const tenant = await this.tenancyContext + .getTenant() + .withGraphFetched('subscriptions') + .withGraphFetched('metadata'); + + throwIfTenantNotExists(tenant); + + return tenant; + } +} diff --git a/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts b/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts index 16ff91c43..88a445c5a 100644 --- a/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts +++ b/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts @@ -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, diff --git a/packages/server-nest/src/modules/Roles/AbilitySchema.ts b/packages/server-nest/src/modules/Roles/AbilitySchema.ts index 8453332d6..36c35cf71 100644 --- a/packages/server-nest/src/modules/Roles/AbilitySchema.ts +++ b/packages/server-nest/src/modules/Roles/AbilitySchema.ts @@ -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[] = [ { diff --git a/packages/server-nest/src/modules/Settings/Settings.types.ts b/packages/server-nest/src/modules/Settings/Settings.types.ts index 6258fa581..47d66a870 100644 --- a/packages/server-nest/src/modules/Settings/Settings.types.ts +++ b/packages/server-nest/src/modules/Settings/Settings.types.ts @@ -10,4 +10,8 @@ export interface ISettingsDTO { } -export const SETTINGS_PROVIDER = 'SETTINGS'; \ No newline at end of file +export const SETTINGS_PROVIDER = 'SETTINGS'; + +export enum PreferencesAction { + Mutate = 'Mutate' +} \ No newline at end of file diff --git a/packages/server-nest/src/modules/StripePayment/StripePayment.module.ts b/packages/server-nest/src/modules/StripePayment/StripePayment.module.ts index 19d86d290..748668124 100644 --- a/packages/server-nest/src/modules/StripePayment/StripePayment.module.ts +++ b/packages/server-nest/src/modules/StripePayment/StripePayment.module.ts @@ -33,6 +33,7 @@ const models = [InjectSystemModel(PaymentIntegration)]; StripeWebhooksSubscriber, TenancyContext, ], + exports: [StripePaymentService], controllers: [StripeIntegrationController], }) export class StripePaymentModule {} diff --git a/packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts b/packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts index e7d8cbaba..75e70a700 100644 --- a/packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts +++ b/packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts @@ -41,7 +41,7 @@ export class SubscriptionApplication { * @param variantId * @returns */ - getLemonSqueezyCheckaoutUri(variantId: number) { + getLemonSqueezyCheckoutUri(variantId: number) { return this.getLemonSqueezyCheckoutService.getCheckout(variantId); } diff --git a/packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts b/packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts index 68232d2dc..c42dd18e8 100644 --- a/packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts +++ b/packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts @@ -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.', diff --git a/packages/server-nest/src/modules/Subscription/models/Plan.ts b/packages/server-nest/src/modules/Subscription/models/Plan.ts index 53b3a5467..40f953cbf 100644 --- a/packages/server-nest/src/modules/Subscription/models/Plan.ts +++ b/packages/server-nest/src/modules/Subscription/models/Plan.ts @@ -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; diff --git a/packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts b/packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts index 37765255a..1aad78b12 100644 --- a/packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts +++ b/packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts @@ -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'); } } diff --git a/packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts b/packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts index 09e77d34f..abe88c4d0 100644 --- a/packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts +++ b/packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts @@ -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' }); } } diff --git a/packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts b/packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts index 329f26119..19a72cd7f 100644 --- a/packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts +++ b/packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts @@ -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, ); } diff --git a/packages/server-nest/src/modules/System/models/TenantModel.ts b/packages/server-nest/src/modules/System/models/TenantModel.ts index d42c68238..b508b5a70 100644 --- a/packages/server-nest/src/modules/System/models/TenantModel.ts +++ b/packages/server-nest/src/modules/System/models/TenantModel.ts @@ -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. diff --git a/packages/server-nest/src/modules/TenantDBManager/TenantDBManager.module.ts b/packages/server-nest/src/modules/TenantDBManager/TenantDBManager.module.ts new file mode 100644 index 000000000..64b47e312 --- /dev/null +++ b/packages/server-nest/src/modules/TenantDBManager/TenantDBManager.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class TenantDBManagerModule {} diff --git a/packages/server-nest/src/modules/TenantDBManager/TenantDBManager.ts b/packages/server-nest/src/modules/TenantDBManager/TenantDBManager.ts new file mode 100644 index 000000000..410d7baf4 --- /dev/null +++ b/packages/server-nest/src/modules/TenantDBManager/TenantDBManager.ts @@ -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} + */ + 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} + */ + public async createDatabase(tenant: ITenant): Promise { + 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} + */ + public async migrate(tenant: ITenant): Promise { + const knex = this.setupKnexInstance(tenant); + await knex.migrate.latest(); + } + + /** + * Seeds initial data to the tenant database. + * @return {Promise} + */ + public async seed(tenant: ITenant): Promise { + 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} + */ + async throwErrorIfTenantDBExists(tenant: ITenant) { + const isExists = await this.databaseExists(tenant); + if (isExists) { + throw new TenantDBAlreadyExists(); + } + } +} diff --git a/packages/server-nest/src/modules/TenantDBManager/TenantsManager.ts b/packages/server-nest/src/modules/TenantDBManager/TenantsManager.ts new file mode 100644 index 000000000..758f5a267 --- /dev/null +++ b/packages/server-nest/src/modules/TenantDBManager/TenantsManager.ts @@ -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} + */ + public async createTenant(): Promise { + const { tenantRepository } = this.sysRepositories; + const tenant = await tenantRepository.createWithUniqueOrgId(); + + return tenant; + } + + /** + * Creates a new tenant database. + * @param {ITenant} tenant - + * @return {Promise} + */ + public async createDatabase(tenant: ITenant): Promise { + 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} + */ + public async hasDatabase(tenant: ITenant): Promise { + return this.tenantDbManager.databaseExists(tenant); + } + + /** + * Migrates the tenant database. + * @param {ITenant} tenant + * @return {Promise} + */ + public async migrateTenant(tenant: ITenant): Promise { + // 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} + */ + public async seedTenant(tenant: ITenant, tenancyContext): Promise { + // 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, + }; + } +} diff --git a/packages/server-nest/src/modules/TenantDBManager/exceptions/TenantDBAlreadyExists.ts b/packages/server-nest/src/modules/TenantDBManager/exceptions/TenantDBAlreadyExists.ts new file mode 100644 index 000000000..e21da82ce --- /dev/null +++ b/packages/server-nest/src/modules/TenantDBManager/exceptions/TenantDBAlreadyExists.ts @@ -0,0 +1,6 @@ + +export class TenantDBAlreadyExists { + constructor() { + + } +} \ No newline at end of file diff --git a/packages/server-nest/src/utils/sanitize-database-name.ts b/packages/server-nest/src/utils/sanitize-database-name.ts new file mode 100644 index 000000000..4ca07a5d8 --- /dev/null +++ b/packages/server-nest/src/utils/sanitize-database-name.ts @@ -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, ''); +}