diff --git a/server/src/api/controllers/Jobs.ts b/server/src/api/controllers/Jobs.ts new file mode 100644 index 000000000..38c5b07a0 --- /dev/null +++ b/server/src/api/controllers/Jobs.ts @@ -0,0 +1,60 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import BaseController from 'api/controllers/BaseController'; +import { ServiceError } from 'exceptions'; +import JobsService from 'services/Jobs/JobsService'; + +@Service() +export default class ItemsController extends BaseController { + @Inject() + jobsService: JobsService; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.get('/:id', this.getJob, this.handlerServiceErrors); + + return router; + } + + /** + * Retrieve job details. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private getJob = async (req: Request, res: Response, next: NextFunction) => { + const { id } = req.params; + + try { + const job = await this.jobsService.getJob(id); + + return res.status(200).send({ + job: this.transfromToResponse(job), + }); + } catch (error) { + next(error); + } + }; + + /** + * Handles service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private handlerServiceErrors = ( + error: Error, + req: Request, + res: Response, + next: NextFunction + ) => { + if (error instanceof ServiceError) { + } + next(error); + }; +} diff --git a/server/src/api/controllers/Organization.ts b/server/src/api/controllers/Organization.ts index 3d50c06f5..6cfcf74b4 100644 --- a/server/src/api/controllers/Organization.ts +++ b/server/src/api/controllers/Organization.ts @@ -1,5 +1,7 @@ import { Inject, Service } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; +import { check, ValidationChain } from 'express-validator'; + import asyncMiddleware from 'api/middleware/asyncMiddleware'; import JWTAuth from 'api/middleware/jwtAuth'; import TenancyMiddleware from 'api/middleware/TenancyMiddleware'; @@ -8,9 +10,9 @@ import AttachCurrentTenantUser from 'api/middleware/AttachCurrentTenantUser'; import OrganizationService from 'services/Organization'; import { ServiceError } from 'exceptions'; import BaseController from 'api/controllers/BaseController'; -import EnsureConfiguredMiddleware from 'api/middleware/EnsureConfiguredMiddleware'; -import SettingsMiddleware from 'api/middleware/SettingsMiddleware'; +const DATE_FORMATS = ['MM/DD/YYYY', 'M/D/YYYY']; +const BASE_CURRENCY = ['USD', 'LYD']; @Service() export default class OrganizationController extends BaseController { @Inject() @@ -28,111 +30,63 @@ export default class OrganizationController extends BaseController { router.use(AttachCurrentTenantUser); router.use(TenancyMiddleware); - // Should to seed organization tenant be configured. - router.use('/seed', SubscriptionMiddleware('main')); - router.use('/seed', SettingsMiddleware); - router.use('/seed', EnsureConfiguredMiddleware); - router.use('/build', SubscriptionMiddleware('main')); - - router.post('/build', asyncMiddleware(this.build.bind(this))); - router.post('/seed', asyncMiddleware(this.seed.bind(this))); - router.get('/all', asyncMiddleware(this.allOrganizations.bind(this))); + router.post( + '/build', + this.buildValidationSchema, + this.validationResult, + asyncMiddleware(this.build.bind(this)), + this.handleServiceErrors.bind(this) + ); + router.put( + '/', + this.asyncMiddleware(this.updateOrganization.bind(this)), + this.handleServiceErrors.bind(this) + ); router.get( - '/current', - asyncMiddleware(this.currentOrganization.bind(this)) + '/', + asyncMiddleware(this.currentOrganization.bind(this)), + this.handleServiceErrors.bind(this) ); return router; } + /** + * Organization setup schema. + */ + private get buildValidationSchema(): ValidationChain[] { + return [ + check('organization_name').exists().trim(), + check('base_currency').exists().isIn(BASE_CURRENCY), + check('timezone').exists(), + check('fiscal_year').exists().isISO8601(), + check('industry').optional().isString(), + check('date_format').optional().isIn(DATE_FORMATS), + ]; + } + /** * Builds tenant database and migrate database schema. * @param {Request} req - Express request. * @param {Response} res - Express response. * @param {NextFunction} next */ - async build(req: Request, res: Response, next: Function) { - const { organizationId } = req.tenant; - const { user } = req; + private async build(req: Request, res: Response, next: Function) { + const { tenantId } = req; + const buildDTO = this.matchedBodyData(req); try { - await this.organizationService.build(organizationId, user); + const result = await this.organizationService.buildRunJob( + tenantId, + buildDTO + ); return res.status(200).send({ type: 'success', code: 'ORGANIZATION.DATABASE.INITIALIZED', message: 'The organization database has been initialized.', + data: result, }); - } catch (error) { - if (error instanceof ServiceError) { - if (error.errorType === 'tenant_not_found') { - return res.status(400).send({ - // errors: [{ type: 'TENANT.NOT.FOUND', code: 100 }], - }); - } - if (error.errorType === 'tenant_already_initialized') { - return res.status(400).send({ - errors: [{ type: 'TENANT.DATABASE.ALREADY.BUILT', code: 200 }], - }); - } - } - next(error); - } - } - - /** - * Seeds initial data to tenant database. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async seed(req: Request, res: Response, next: Function) { - const { organizationId } = req.tenant; - - try { - await this.organizationService.seed(organizationId); - - return res.status(200).send({ - type: 'success', - code: 'ORGANIZATION.DATABASE.SEED', - message: 'The organization database has been seeded.', - }); - } catch (error) { - if (error instanceof ServiceError) { - if (error.errorType === 'tenant_not_found') { - return res.status(400).send({ - errors: [{ type: 'TENANT.NOT.FOUND', code: 100 }], - }); - } - if (error.errorType === 'tenant_already_seeded') { - return res.status(400).send({ - errors: [{ type: 'TENANT.DATABASE.ALREADY.SEEDED', code: 200 }], - }); - } - if (error.errorType === 'tenant_db_not_built') { - return res.status(400).send({ - errors: [{ type: 'TENANT.DATABASE.NOT.BUILT', code: 300 }], - }); - } - } - next(error); - } - } - - /** - * Listing all organizations that assocaited to the authorized user. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async allOrganizations(req: Request, res: Response, next: NextFunction) { - const { user } = req; - - try { - const organizations = await this.organizationService.listOrganizations( - user - ); - return res.status(200).send({ organizations }); } catch (error) { next(error); } @@ -144,7 +98,11 @@ export default class OrganizationController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - async currentOrganization(req: Request, res: Response, next: NextFunction) { + private async currentOrganization( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId } = req; try { @@ -156,4 +114,68 @@ export default class OrganizationController extends BaseController { next(error); } } + + /** + * Update the organization information. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + private async updateOrganization( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const tenantDTO = this.matchedBodyData(req); + + try { + const organization = await this.organizationService.updateOrganization( + tenantId, + tenantDTO + ); + return res.status(200).send( + this.transfromToResponse({ + tenantId, + message: 'Organization information has been updated successfully.', + }) + ); + } catch (error) { + next(error); + } + } + + /** + * Handles service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private handleServiceErrors( + error: Error, + req: Request, + res: Response, + next: NextFunction + ) { + if (error instanceof ServiceError) { + if (error.errorType === 'tenant_not_found') { + return res.status(400).send({ + errors: [{ type: 'TENANT.NOT.FOUND', code: 100 }], + }); + } + if (error.errorType === 'TENANT_ALREADY_BUILT') { + return res.status(400).send({ + errors: [{ type: 'TENANT_ALREADY_BUILT', code: 200 }], + }); + } + if (error.errorType === 'TENANT_IS_BUILDING') { + return res.status(400).send({ + errors: [{ type: 'TENANT_IS_BUILDING', code: 300 }], + }); + } + } + next(error); + } } diff --git a/server/src/api/index.ts b/server/src/api/index.ts index ce6540463..9505b049b 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -11,7 +11,6 @@ import EnsureTenantIsInitialized from 'api/middleware/EnsureTenantIsInitialized' import SettingsMiddleware from 'api/middleware/SettingsMiddleware'; import I18nMiddleware from 'api/middleware/I18nMiddleware'; import I18nAuthenticatedMiddlware from 'api/middleware/I18nAuthenticatedMiddlware'; -import EnsureConfiguredMiddleware from 'api/middleware/EnsureConfiguredMiddleware'; import EnsureTenantIsSeeded from 'api/middleware/EnsureTenantIsSeeded'; // Routes @@ -41,8 +40,8 @@ import Ping from 'api/controllers/Ping'; import Subscription from 'api/controllers/Subscription'; import Licenses from 'api/controllers/Subscription/Licenses'; import InventoryAdjustments from 'api/controllers/Inventory/InventoryAdjustments'; -import Setup from 'api/controllers/Setup'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; +import Jobs from './controllers/Jobs'; export default () => { const app = Router(); @@ -59,7 +58,7 @@ export default () => { app.use('/subscription', Container.get(Subscription).router()); app.use('/organization', Container.get(Organization).router()); app.use('/ping', Container.get(Ping).router()); - app.use('/setup', Container.get(Setup).router()); + app.use('/jobs', Container.get(Jobs).router()); // - Dashboard routes. // --------------------------- @@ -72,7 +71,6 @@ export default () => { dashboard.use(EnsureTenantIsInitialized); dashboard.use(SettingsMiddleware); dashboard.use(I18nAuthenticatedMiddlware); - dashboard.use(EnsureConfiguredMiddleware); dashboard.use(EnsureTenantIsSeeded); dashboard.use('/users', Container.get(Users).router()); diff --git a/server/src/api/middleware/EnsureConfiguredMiddleware.ts b/server/src/api/middleware/EnsureConfiguredMiddleware.ts deleted file mode 100644 index 1509d4d25..000000000 --- a/server/src/api/middleware/EnsureConfiguredMiddleware.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; - -export default (req: Request, res: Response, next: NextFunction) => { - const { settings } = req; - - if (!settings.get('app_configured', false)) { - return res.boom.badRequest(null, { - errors: [{ type: 'APP.NOT.CONFIGURED', code: 100 }], - }); - } - next(); -}; diff --git a/server/src/interfaces/Jobs.ts b/server/src/interfaces/Jobs.ts new file mode 100644 index 000000000..9c40bcd43 --- /dev/null +++ b/server/src/interfaces/Jobs.ts @@ -0,0 +1,14 @@ +export interface IJobMeta { + id: string; + nextRunAt: Date; + lastModifiedBy: null | Date; + lockedAt: null | Date; + lastRunAt: null | Date; + failCount: number; + failedAt: null | Date; + lastFinishedAt: Date | null; + running: boolean; + queued: boolean; + completed: boolean; + failed: boolean; +} diff --git a/server/src/interfaces/Setup.ts b/server/src/interfaces/Setup.ts index 2edf91495..e7ea23ff1 100644 --- a/server/src/interfaces/Setup.ts +++ b/server/src/interfaces/Setup.ts @@ -7,4 +7,20 @@ export interface IOrganizationSetupDTO{ fiscalYear: string, industry: string, timeZone: string, +} + +export interface IOrganizationBuildDTO { + organizationName: string; + baseCurrency: string, + timezone: string; + fiscalYear: string; + industry: string; +} + +export interface IOrganizationUpdateDTO { + organizationName: string; + baseCurrency: string, + timezone: string; + fiscalYear: string; + industry: string; } \ No newline at end of file diff --git a/server/src/interfaces/Tenancy.ts b/server/src/interfaces/Tenancy.ts index e7b1e354a..a49182bc8 100644 --- a/server/src/interfaces/Tenancy.ts +++ b/server/src/interfaces/Tenancy.ts @@ -6,6 +6,7 @@ export interface ITenant { initializedAt: Date|null, seededAt: Date|null, + builtAt: Date|null, createdAt: Date|null, } diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 3008dda21..e67409173 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -56,6 +56,7 @@ export * from './InventoryDetails'; export * from './LandedCost'; export * from './Entry'; export * from './TransactionsByReference'; +export * from './Jobs'; export interface I18nService { __: (input: string) => string; diff --git a/server/src/jobs/OrganizationSetup.ts b/server/src/jobs/OrganizationSetup.ts new file mode 100644 index 000000000..1bb12a42c --- /dev/null +++ b/server/src/jobs/OrganizationSetup.ts @@ -0,0 +1,32 @@ +import { Container } from 'typedi'; +import OrganizationService from 'services/Organization'; + +export default class OrganizationSetupJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'organization-setup', + { priority: 'high', concurrency: 1 }, + this.handler + ); + } + + /** + * Handle job action. + */ + async handler(job, done: Function): Promise { + const { tenantId, _id } = job.attrs.data; + const licenseService = Container.get(OrganizationService); + + try { + await licenseService.build(tenantId); + done(); + } catch (e) { + // Unlock build status of the tenant. + await licenseService.revertBuildRunJob(tenantId, _id); + done(e); + } + } +} diff --git a/server/src/lib/Transformer/Transformer.ts b/server/src/lib/Transformer/Transformer.ts index d9a34beb2..2312d6555 100644 --- a/server/src/lib/Transformer/Transformer.ts +++ b/server/src/lib/Transformer/Transformer.ts @@ -3,7 +3,7 @@ import { isEmpty, isObject, isUndefined } from 'lodash'; export class Transformer { /** - * + * Includeded attributes. * @returns */ protected includeAttributes = (): string[] => { diff --git a/server/src/loaders/jobs.ts b/server/src/loaders/jobs.ts index c02bfa624..916a0aba3 100644 --- a/server/src/loaders/jobs.ts +++ b/server/src/loaders/jobs.ts @@ -11,6 +11,7 @@ import SendSMSNotificationTrialEnd from 'jobs/SMSNotificationTrialEnd'; import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd'; import SendMailNotificationTrialEnd from 'jobs/MailNotificationTrialEnd'; import UserInviteMailJob from 'jobs/UserInviteMail'; +import OrganizationSetupJob from 'jobs/OrganizationSetup'; export default ({ agenda }: { agenda: Agenda }) => { new WelcomeEmailJob(agenda); @@ -21,6 +22,7 @@ export default ({ agenda }: { agenda: Agenda }) => { new SendLicenseViaPhoneJob(agenda); new ComputeItemCost(agenda); new RewriteInvoicesJournalEntries(agenda); + new OrganizationSetupJob(agenda); agenda.define( 'send-sms-notification-subscribe-end', diff --git a/server/src/services/Jobs/JobTransformer.ts b/server/src/services/Jobs/JobTransformer.ts new file mode 100644 index 000000000..ad1c8a86e --- /dev/null +++ b/server/src/services/Jobs/JobTransformer.ts @@ -0,0 +1,47 @@ +import { Service } from 'typedi'; +import moment from 'moment'; +import { ISaleInvoice } from 'interfaces'; +import { Transformer } from 'lib/Transformer/Transformer'; +import { formatNumber } from 'utils'; + +@Service() +export default class JobTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + protected includeAttributes = (): string[] => { + return ['queued', 'completed', 'failed']; + }; + + /** + * Detarmines the queued state. + * @param {IJob} job + * @returns {String} + */ + protected queued = (job): boolean => { + return !!job.nextRunAt && moment().isSameOrAfter(job.nextRunAt, 'seconds'); + }; + + /** + * Detarmines the completed state. + * @param job + * @returns + */ + protected completed = (job): boolean => { + return !!job.lastFinishedAt; + }; + + /** + * Detarmines the failed state. + * @param job + * @returns + */ + protected failed = (job): boolean => { + return ( + job.lastFinishedAt && + job.failedAt && + moment(job.failedAt).isSame(job.lastFinishedAt) + ); + }; +} diff --git a/server/src/services/Jobs/JobsService.ts b/server/src/services/Jobs/JobsService.ts new file mode 100644 index 000000000..7c1890524 --- /dev/null +++ b/server/src/services/Jobs/JobsService.ts @@ -0,0 +1,50 @@ +import { pick, first } from 'lodash'; +import { ObjectId } from 'mongodb'; +import { Service, Inject } from 'typedi'; +import JobTransformer from './JobTransformer'; +import { IJobMeta } from 'interfaces'; + +@Service() +export default class JobsService { + @Inject('agenda') + agenda: any; + + @Inject() + jobsTransformer: JobTransformer; + + /** + * Retrieve job details of the given job id. + * @param {string} jobId + * @returns {Promise} + */ + async getJob(jobId: string): Promise { + const jobs = await this.agenda.jobs({ _id: new ObjectId(jobId) }); + + // Transformes job to json. + const jobJson = this.transformJobToJson(first(jobs)); + + return this.jobsTransformer.transform(jobJson); + } + + /** + * Transformes the job to json. + * @param job + * @returns + */ + private transformJobToJson(job) { + return { + id: job.attrs._id, + ...pick(job.attrs, [ + 'nextRunAt', + 'lastModifiedBy', + 'lockedAt', + 'lastRunAt', + 'failCount', + 'failReason', + 'failedAt', + 'lastFinishedAt', + ]), + running: job.isRunning(), + }; + } +} diff --git a/server/src/services/Organization/index.ts b/server/src/services/Organization/index.ts index e81ed40d8..47eaae794 100644 --- a/server/src/services/Organization/index.ts +++ b/server/src/services/Organization/index.ts @@ -1,6 +1,8 @@ import { Service, Inject } from 'typedi'; +import { Container } from 'typedi'; +// import { ObjectId } from 'mongoose'; import { ServiceError } from 'exceptions'; -import { ISystemService, ISystemUser, ITenant } from 'interfaces'; +import { IOrganizationBuildDTO, ISystemUser, ITenant } from 'interfaces'; import { EventDispatcher, EventDispatcherInterface, @@ -12,12 +14,15 @@ import { TenantDatabaseNotBuilt, } from 'exceptions'; import TenantsManager from 'services/Tenancy/TenantsManager'; +import { Tenant, TenantMetadata } from 'system/models'; +import { ObjectId } from 'mongodb'; const ERRORS = { TENANT_NOT_FOUND: 'tenant_not_found', - TENANT_ALREADY_INITIALIZED: 'tenant_already_initialized', + 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', }; @Service() @@ -34,92 +39,96 @@ export default class OrganizationService { @Inject() tenantsManager: TenantsManager; + @Inject('agenda') + agenda: any; + /** * Builds the database schema and seed data of the given organization id. * @param {srting} organizationId * @return {Promise} */ - public async build(organizationId: string, user: ISystemUser): Promise { - const tenant = await this.getTenantByOrgIdOrThrowError(organizationId); + public async build(tenantId: number): Promise { + const tenant = await this.getTenantOrThrowError(tenantId); + + // Throw error if the tenant is already initialized. this.throwIfTenantInitizalized(tenant); - const tenantHasDB = await this.tenantsManager.hasDatabase(tenant); + // Drop the database if is already exists. + await this.tenantsManager.dropDatabaseIfExists(tenant); - try { - if (!tenantHasDB) { - this.logger.info('[organization] trying to create tenant database.', { - organizationId, userId: user.id, - }); - await this.tenantsManager.createDatabase(tenant); - } - this.logger.info('[organization] trying to migrate tenant database.', { - organizationId, userId: user.id, - }); - await this.tenantsManager.migrateTenant(tenant); + // Creates a new database. + await this.tenantsManager.createDatabase(tenant); - // Throws `onOrganizationBuild` event. - this.eventDispatcher.dispatch(events.organization.build, { tenant, user }); - } catch (error) { - if (error instanceof TenantAlreadyInitialized) { - throw new ServiceError(ERRORS.TENANT_ALREADY_INITIALIZED); - } else { - throw error; - } - } - } + // Migrate the tenant. + const migratedTenant = await this.tenantsManager.migrateTenant(tenant); - /** - * Seeds initial core data to the given organization tenant. - * @param {number} organizationId - * @return {Promise} - */ - public async seed(organizationId: string): Promise { - const tenant = await this.getTenantByOrgIdOrThrowError(organizationId); - this.throwIfTenantSeeded(tenant); + // Seed tenant. + const seededTenant = await this.tenantsManager.seedTenant(migratedTenant); - try { - this.logger.info('[organization] trying to seed tenant database.', { - organizationId, - }); - await this.tenantsManager.seedTenant(tenant); + // Markes the tenant as completed builing. + await Tenant.markAsBuilt(tenantId); + await Tenant.markAsBuildCompleted(tenantId); - // Throws `onOrganizationBuild` event. - this.eventDispatcher.dispatch(events.organization.seeded, { tenant }); - } catch (error) { - if (error instanceof TenantAlreadySeeded) { - throw new ServiceError(ERRORS.TENANT_ALREADY_SEEDED); - } else if (error instanceof TenantDatabaseNotBuilt) { - throw new ServiceError(ERRORS.TENANT_DB_NOT_BUILT); - } else { - throw error; - } - } - } - - /** - * Listing all associated organizations to the given user. - * @param {ISystemUser} user - - * @return {Promise} - */ - public async listOrganizations(user: ISystemUser): Promise { - this.logger.info('[organization] trying to list all organizations.', { - user, + // Throws `onOrganizationBuild` event. + this.eventDispatcher.dispatch(events.organization.build, { + tenant: seededTenant, }); + } - const { tenantRepository } = this.sysRepositories; - const tenant = await tenantRepository.findOneById(user.tenantId); + /** + * + * @param tenantId + * @param buildDTO + * @returns + */ + async buildRunJob(tenantId: number, buildDTO: IOrganizationBuildDTO) { + const tenant = await this.getTenantOrThrowError(tenantId); - return [tenant]; + // Throw error if the tenant is already initialized. + this.throwIfTenantInitizalized(tenant); + + // Throw error if tenant is currently building. + this.throwIfTenantIsBuilding(tenant); + + // Saves the tenant metadata. + await this.saveTenantMetadata(tenant, buildDTO); + + // Send welcome mail to the user. + const jobMeta = await this.agenda.now('organization-setup', { + tenantId, + buildDTO, + }); + 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, + }; + } + + throwIfTenantIsBuilding(tenant) { + if (tenant.buildJobId) { + throw new ServiceError(ERRORS.TENANT_IS_BUILDING); + } + } + + public async revertBuildRunJob(tenantId: number, jobId: string) { + await Tenant.markAsBuildCompleted(tenantId, jobId); } /** * Retrieve the current organization metadata. - * @param {number} tenantId - * @returns {Promise} + * @param {number} tenantId + * @returns {Promise} */ - public async currentOrganization(tenantId: number): Promise { - const { tenantRepository } = this.sysRepositories; - const tenant = await tenantRepository.findOneById(tenantId, ['subscriptions']); + public async currentOrganization(tenantId: number): Promise { + const tenant = await Tenant.query() + .findById(tenantId) + .withGraphFetched('subscriptions') + .withGraphFetched('metadata'); this.throwIfTenantNotExists(tenant); @@ -132,7 +141,6 @@ export default class OrganizationService { */ private throwIfTenantNotExists(tenant: ITenant) { if (!tenant) { - this.logger.info('[tenant_db_build] organization id not found.'); throw new ServiceError(ERRORS.TENANT_NOT_FOUND); } } @@ -142,33 +150,34 @@ export default class OrganizationService { * @param {ITenant} tenant */ private throwIfTenantInitizalized(tenant: ITenant) { - if (tenant.initializedAt) { - throw new ServiceError(ERRORS.TENANT_ALREADY_INITIALIZED); + if (tenant.builtAt) { + throw new ServiceError(ERRORS.TENANT_ALREADY_BUILT); } } /** - * Throws service if the tenant already seeded. - * @param {ITenant} tenant + * Saves the organization metadata. + * @param tenant + * @param buildDTO + * @returns */ - private throwIfTenantSeeded(tenant: ITenant) { - if (tenant.seededAt) { - throw new ServiceError(ERRORS.TENANT_ALREADY_SEEDED); - } + private saveTenantMetadata(tenant: ITenant, buildDTO) { + return TenantMetadata.query().insert({ + tenantId: tenant.id, + ...buildDTO, + }); } /** - * Retrieve tenant model by the given organization id or throw not found - * error if the tenant not exists on the storage. - * @param {string} organizationId - * @return {ITenant} + * Retrieve tenant of throw not found error. + * @param {number} tenantId - */ - private async getTenantByOrgIdOrThrowError(organizationId: string) { - const { tenantRepository } = this.sysRepositories; - const tenant = await tenantRepository.findOne({ organizationId }); - - this.throwIfTenantNotExists(tenant); + async getTenantOrThrowError(tenantId: number): Promise { + const tenant = await Tenant.query().findById(tenantId); + if (!tenant) { + throw new ServiceError(ERRORS.TENANT_NOT_FOUND); + } return tenant; } } diff --git a/server/src/services/Tenancy/TenantDBManager.ts b/server/src/services/Tenancy/TenantDBManager.ts index a0e2d7786..1a1f03973 100644 --- a/server/src/services/Tenancy/TenantDBManager.ts +++ b/server/src/services/Tenancy/TenantDBManager.ts @@ -7,8 +7,8 @@ import SystemService from 'services/Tenancy/SystemService'; import { TenantDBAlreadyExists } from 'exceptions'; import { tenantKnexConfig, tenantSeedConfig } from 'config/knexConfig'; -export default class TenantDBManager implements ITenantDBManager{ - static knexCache: { [key: string]: Knex; } = {}; +export default class TenantDBManager implements ITenantDBManager { + static knexCache: { [key: string]: Knex } = {}; // System database manager. dbManager: any; @@ -18,7 +18,7 @@ export default class TenantDBManager implements ITenantDBManager{ /** * Constructor method. - * @param {ITenant} tenant + * @param {ITenant} tenant */ constructor() { const systemService = Container.get(SystemService); @@ -41,8 +41,12 @@ export default class TenantDBManager implements ITenantDBManager{ */ public async databaseExists(tenant: ITenant) { const databaseName = this.getDatabaseName(tenant); - const results = await this.sysKnex - .raw('SELECT * FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = "?"', databaseName); + + const results = await this.sysKnex.raw( + 'SELECT * FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = "' + + databaseName + + '"' + ); return results[0].length > 0; } @@ -59,6 +63,30 @@ export default class TenantDBManager implements ITenantDBManager{ await this.dbManager.createDb(databaseName); } + /** + * 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.dbManager.dropDb(databaseName); + } + /** * Migrate tenant database schema to the latest version. * @return {Promise} @@ -100,6 +128,9 @@ export default class TenantDBManager implements ITenantDBManager{ return knexInstance; } + /** + * Retrieve knex instance from the givne tenant. + */ public getKnexInstance(tenantId: number) { const key: string = `${tenantId}`; let knexInstance = TenantDBManager.knexCache[key]; @@ -120,4 +151,4 @@ export default class TenantDBManager implements ITenantDBManager{ throw new TenantDBAlreadyExists(); } } -} \ No newline at end of file +} diff --git a/server/src/services/Tenancy/TenantsManager.ts b/server/src/services/Tenancy/TenantsManager.ts index 0190fe78f..2bdc2b6d2 100644 --- a/server/src/services/Tenancy/TenantsManager.ts +++ b/server/src/services/Tenancy/TenantsManager.ts @@ -1,26 +1,27 @@ import { Container, Inject, Service } from 'typedi'; import { ServiceError } from 'exceptions'; -import { - ITenantManager, - ITenant, - ITenantDBManager, -} from 'interfaces'; +import { ITenantManager, ITenant, ITenantDBManager } from 'interfaces'; import { EventDispatcherInterface, EventDispatcher, } from 'decorators/eventDispatcher'; -import { TenantAlreadyInitialized, TenantAlreadySeeded, TenantDatabaseNotBuilt } from 'exceptions'; +import { + TenantAlreadyInitialized, + TenantAlreadySeeded, + TenantDatabaseNotBuilt, +} from 'exceptions'; import TenantDBManager from 'services/Tenancy/TenantDBManager'; import events from 'subscribers/events'; +import { Tenant } from 'system/models'; const ERRORS = { TENANT_ALREADY_CREATED: 'TENANT_ALREADY_CREATED', - TENANT_NOT_EXISTS: 'TENANT_NOT_EXISTS' + TENANT_NOT_EXISTS: 'TENANT_NOT_EXISTS', }; // Tenants manager service. @Service() -export default class TenantsManagerService implements ITenantManager{ +export default class TenantsManagerService implements ITenantManager { static instances: { [key: number]: ITenantManager } = {}; @EventDispatcher() @@ -34,7 +35,7 @@ export default class TenantsManagerService implements ITenantManager{ /** * Constructor method. */ - constructor() { + constructor() { this.tenantDBManager = new TenantDBManager(); } @@ -52,17 +53,26 @@ export default class TenantsManagerService implements ITenantManager{ /** * Creates a new tenant database. - * @param {ITenant} tenant - + * @param {ITenant} tenant - * @return {Promise} */ public async createDatabase(tenant: ITenant): Promise { this.throwErrorIfTenantAlreadyInitialized(tenant); await this.tenantDBManager.createDatabase(tenant); - + this.eventDispatcher.dispatch(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 @@ -77,15 +87,20 @@ export default class TenantsManagerService implements ITenantManager{ * @param {ITenant} tenant * @return {Promise} */ - public async migrateTenant(tenant: ITenant) { + public async migrateTenant(tenant: ITenant): Promise { + // Throw error if the tenant already initialized. this.throwErrorIfTenantAlreadyInitialized(tenant); - const { tenantRepository } = this.sysRepositories; - + // Migrate the database tenant. await this.tenantDBManager.migrate(tenant); - await tenantRepository.markAsInitialized(tenant.id); - this.eventDispatcher.dispatch(events.tenantManager.tenantMigrated, { tenant }); + // Mark the tenant as initialized. + await Tenant.markAsInitialized(tenant.id); + + // Triggers `onTenantMigrated` event. + this.eventDispatcher.dispatch(events.tenantManager.tenantMigrated, { + tenantId: tenant.id, + }); } /** @@ -93,19 +108,23 @@ export default class TenantsManagerService implements ITenantManager{ * @param {ITenant} tenant * @return {Promise} */ - public async seedTenant(tenant: ITenant) { + public async seedTenant(tenant: ITenant): Promise { + // Throw error if the tenant is not built yet. this.throwErrorIfTenantNotBuilt(tenant); - this.throwErrorIfTenantAlreadySeeded(tenant); - const { tenantRepository } = this.sysRepositories; + // Throw error if the tenant is not seeded yet. + this.throwErrorIfTenantAlreadySeeded(tenant); // Seed the tenant database. await this.tenantDBManager.seed(tenant); // Mark the tenant as seeded in specific date. - await tenantRepository.markAsSeeded(tenant.id); + await Tenant.markAsSeeded(tenant.id); - this.eventDispatcher.dispatch(events.tenantManager.tenantSeeded); + // Triggers `onTenantSeeded` event. + this.eventDispatcher.dispatch(events.tenantManager.tenantSeeded, { + tenantId: tenant.id, + }); } /** @@ -138,7 +157,7 @@ export default class TenantsManagerService implements ITenantManager{ /** * Throws error if the tenant database is not built yut. - * @param {ITenant} tenant + * @param {ITenant} tenant */ private throwErrorIfTenantNotBuilt(tenant: ITenant) { if (!tenant.initializedAt) { diff --git a/server/src/system/migrations/20200420134631_create_tenants_table.js b/server/src/system/migrations/20200420134631_create_tenants_table.js index ef968abf9..08940348b 100644 --- a/server/src/system/migrations/20200420134631_create_tenants_table.js +++ b/server/src/system/migrations/20200420134631_create_tenants_table.js @@ -7,6 +7,9 @@ exports.up = function(knex) { table.dateTime('under_maintenance_since').nullable(); table.dateTime('initialized_at').nullable(); table.dateTime('seeded_at').nullable(); + table.dateTime('built_at').nullable(); + table.string('build_job_id'); + table.timestamps(); }); }; diff --git a/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js b/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js index acdc9a0e6..267be4614 100644 --- a/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js +++ b/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js @@ -7,9 +7,6 @@ exports.up = function(knex) { table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); table.bigInteger('tenant_id').unsigned().index().references('id').inTable('tenants'); - table.dateTime('trial_started_at').nullable(); - table.dateTime('trial_ends_at').nullable(); - table.dateTime('starts_at').nullable(); table.dateTime('ends_at').nullable(); diff --git a/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js b/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js index 206721cb2..6babd6f03 100644 --- a/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js +++ b/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js @@ -1,6 +1,6 @@ exports.up = function(knex) { - return knex.schema.createTable('subscription_licenses', table => { + return knex.schema.createTable('subscription_licenses', (table) => { table.increments(); table.string('license_code').unique().index(); diff --git a/server/src/system/migrations/20200823235340_create_tenants_metadata_table.js b/server/src/system/migrations/20200823235340_create_tenants_metadata_table.js new file mode 100644 index 000000000..ef3fd6332 --- /dev/null +++ b/server/src/system/migrations/20200823235340_create_tenants_metadata_table.js @@ -0,0 +1,21 @@ +exports.up = function (knex) { + return knex.schema.createTable('tenants_metadata', (table) => { + table.bigIncrements(); + table.integer('tenant_id').unsigned(); + + table.string('organization_name'); + table.string('industry'); + + table.string('base_currency'); + + table.string('timezone'); + table.string('date_format'); + + table.string('fiscal_year'); + table.string('financial_start_date'); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenants_metadata'); +}; diff --git a/server/src/system/models/Tenant.js b/server/src/system/models/Tenant.js index 8c9b1688d..0dc5110b6 100644 --- a/server/src/system/models/Tenant.js +++ b/server/src/system/models/Tenant.js @@ -1,6 +1,9 @@ -import BaseModel from 'models/Model'; +import moment from 'moment'; import { Model } from 'objection'; +import uniqid from 'uniqid'; import SubscriptionPeriod from 'services/Subscription/SubscriptionPeriod'; +import BaseModel from 'models/Model'; +import TenantMetadata from './TenantMetadata'; export default class Tenant extends BaseModel { /** @@ -21,7 +24,7 @@ export default class Tenant extends BaseModel { * Virtual attributes. */ static get virtualAttributes() { - return ['isReady']; + return ['isReady', 'isBuildRunning']; } /** @@ -31,6 +34,13 @@ export default class Tenant extends BaseModel { return !!(this.initializedAt && this.seededAt); } + /** + * Detarimes the tenant whether is build currently running. + */ + get isBuildRunning() { + return !!this.buildJobId; + } + /** * Query modifiers. */ @@ -47,6 +57,7 @@ export default class Tenant extends BaseModel { */ static get relationMappings() { const PlanSubscription = require('./Subscriptions/PlanSubscription'); + const TenantMetadata = require('./TenantMetadata'); return { subscriptions: { @@ -55,9 +66,17 @@ export default class Tenant extends BaseModel { join: { from: 'tenants.id', to: 'subscription_plan_subscriptions.tenantId', - } + }, }, - } + metadata: { + relation: Model.HasOneRelation, + modelClass: TenantMetadata.default, + join: { + from: 'tenants.id', + to: 'tenants_metadata.tenantId', + }, + }, + }; } /** @@ -71,22 +90,90 @@ export default class Tenant extends BaseModel { /** * Records a new subscription for the associated tenant. - * @param {string} subscriptionSlug - * @param {IPlan} plan */ newSubscription(subscriptionSlug, plan) { - const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod) - const period = new SubscriptionPeriod(plan.invoiceInterval, plan.invoicePeriod, trial.getEndDate()); + const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod); + const period = new SubscriptionPeriod( + plan.invoiceInterval, + plan.invoicePeriod, + trial.getEndDate() + ); return this.$relatedQuery('subscriptions').insert({ slug: subscriptionSlug, planId: plan.id, - - trialStartedAt: trial.getStartDate(), - trialEndsAt: trial.getEndDate(), - startsAt: period.getStartDate(), endsAt: period.getEndDate(), }); } + + /** + * Creates a new tenant with random organization id. + */ + static createWithUniqueOrgId(uniqId) { + const organizationId = uniqid() || uniqId; + return this.query().insert({ organizationId }); + } + + /** + * Mark as seeded. + * @param {number} tenantId + */ + static markAsSeeded(tenantId) { + const seededAt = moment().toMySqlDateTime(); + return this.query().update({ seededAt }).where({ id: tenantId }); + } + + /** + * Mark the the given organization as initialized. + * @param {string} organizationId + */ + static markAsInitialized(tenantId) { + const initializedAt = moment().toMySqlDateTime(); + return this.query().update({ initializedAt }).where({ id: tenantId }); + } + + /** + * Marks the given tenant as built. + */ + static markAsBuilt(tenantId) { + const builtAt = moment().toMySqlDateTime(); + return this.query().update({ builtAt }).where({ id: tenantId }); + } + + /** + * Marks the given tenant as built. + */ + static markAsBuilding(tenantId, buildJobId) { + return this.query().update({ buildJobId }).where({ id: tenantId }); + } + + /** + * Marks the given tenant as built. + */ + static markAsBuildCompleted(tenantId) { + return this.query().update({ buildJobId: null }).where({ id: tenantId }); + } + + /** + * Saves the metadata of the given tenant. + */ + static async saveMetadata(tenantId, metadata) { + const foundMetadata = await TenantMetadata.query().findOne({ tenantId }); + const updateOrInsert = foundMetadata ? 'update' : 'insert'; + + return TenantMetadata.query() + [updateOrInsert]({ + tenantId, + ...metadata, + }) + .where({ tenantId }); + } + + /** + * Saves the metadata of the tenant. + */ + saveMetadata(metadata) { + return Tenant.saveMetadata(this.id, metadata); + } } diff --git a/server/src/system/models/TenantMetadata.js b/server/src/system/models/TenantMetadata.js new file mode 100644 index 000000000..4664cfd6d --- /dev/null +++ b/server/src/system/models/TenantMetadata.js @@ -0,0 +1,10 @@ +import BaseModel from 'models/Model'; + +export default class TenantMetadata extends BaseModel { + /** + * Table name. + */ + static get tableName() { + return 'tenants_metadata'; + } +} diff --git a/server/src/system/models/index.js b/server/src/system/models/index.js index 2030ad3f3..6e5ee2d80 100644 --- a/server/src/system/models/index.js +++ b/server/src/system/models/index.js @@ -4,6 +4,7 @@ import PlanFeature from './Subscriptions/PlanFeature'; import PlanSubscription from './Subscriptions/PlanSubscription'; import License from './Subscriptions/License'; import Tenant from './Tenant'; +import TenantMetadata from './TenantMetadata'; import SystemUser from './SystemUser'; import PasswordReset from './PasswordReset'; import Invite from './Invite'; @@ -14,6 +15,7 @@ export { PlanSubscription, License, Tenant, + TenantMetadata, SystemUser, PasswordReset, Invite, diff --git a/server/src/system/seeds/seed_subscriptions_plans.js b/server/src/system/seeds/seed_subscriptions_plans.js index 53f070dc5..0e69b94db 100644 --- a/server/src/system/seeds/seed_subscriptions_plans.js +++ b/server/src/system/seeds/seed_subscriptions_plans.js @@ -6,18 +6,35 @@ exports.seed = (knex) => { // Inserts seed entries return knex('subscription_plans').insert([ { - name: 'Free', - slug: 'free', - price: 0, + name: 'Essentials', + slug: 'essentials-monthly', + price: 100, active: true, currency: 'LYD', trial_period: 7, trial_interval: 'days', - index: 1, }, { - name: 'Starter', - slug: 'starter', + name: 'Essentials', + slug: 'essentials-yearly', + price: 1200, + active: true, + currency: 'LYD', + trial_period: 12, + trial_interval: 'months', + }, + { + name: 'Pro', + slug: 'pro-monthly', + price: 200, + active: true, + currency: 'LYD', + trial_period: 1, + trial_interval: 'months', + }, + { + name: 'Pro', + slug: 'pro-yearly', price: 500, active: true, currency: 'LYD', @@ -26,14 +43,23 @@ exports.seed = (knex) => { index: 2, }, { - name: 'Growth', - slug: 'growth', - price: 1000, + name: 'Plus', + slug: 'plus-monthly', + price: 200, + active: true, + currency: 'LYD', + trial_period: 1, + trial_interval: 'months', + }, + { + name: 'Plus', + slug: 'plus-yearly', + price: 500, active: true, currency: 'LYD', invoice_period: 12, invoice_interval: 'month', - index: 3, + index: 2, }, ]); });