feat: organization metadata redesigned.

This commit is contained in:
a.bouhuolia
2021-09-04 09:49:26 +02:00
parent 4706519121
commit f2c51c6023
24 changed files with 681 additions and 245 deletions

View File

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

View File

@@ -1,5 +1,7 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { check, ValidationChain } from 'express-validator';
import asyncMiddleware from 'api/middleware/asyncMiddleware'; import asyncMiddleware from 'api/middleware/asyncMiddleware';
import JWTAuth from 'api/middleware/jwtAuth'; import JWTAuth from 'api/middleware/jwtAuth';
import TenancyMiddleware from 'api/middleware/TenancyMiddleware'; import TenancyMiddleware from 'api/middleware/TenancyMiddleware';
@@ -8,9 +10,9 @@ import AttachCurrentTenantUser from 'api/middleware/AttachCurrentTenantUser';
import OrganizationService from 'services/Organization'; import OrganizationService from 'services/Organization';
import { ServiceError } from 'exceptions'; import { ServiceError } from 'exceptions';
import BaseController from 'api/controllers/BaseController'; 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() @Service()
export default class OrganizationController extends BaseController { export default class OrganizationController extends BaseController {
@Inject() @Inject()
@@ -28,111 +30,63 @@ export default class OrganizationController extends BaseController {
router.use(AttachCurrentTenantUser); router.use(AttachCurrentTenantUser);
router.use(TenancyMiddleware); 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.use('/build', SubscriptionMiddleware('main'));
router.post(
router.post('/build', asyncMiddleware(this.build.bind(this))); '/build',
router.post('/seed', asyncMiddleware(this.seed.bind(this))); this.buildValidationSchema,
router.get('/all', asyncMiddleware(this.allOrganizations.bind(this))); 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( router.get(
'/current', '/',
asyncMiddleware(this.currentOrganization.bind(this)) asyncMiddleware(this.currentOrganization.bind(this)),
this.handleServiceErrors.bind(this)
); );
return router; 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. * Builds tenant database and migrate database schema.
* @param {Request} req - Express request. * @param {Request} req - Express request.
* @param {Response} res - Express response. * @param {Response} res - Express response.
* @param {NextFunction} next * @param {NextFunction} next
*/ */
async build(req: Request, res: Response, next: Function) { private async build(req: Request, res: Response, next: Function) {
const { organizationId } = req.tenant; const { tenantId } = req;
const { user } = req; const buildDTO = this.matchedBodyData(req);
try { try {
await this.organizationService.build(organizationId, user); const result = await this.organizationService.buildRunJob(
tenantId,
buildDTO
);
return res.status(200).send({ return res.status(200).send({
type: 'success', type: 'success',
code: 'ORGANIZATION.DATABASE.INITIALIZED', code: 'ORGANIZATION.DATABASE.INITIALIZED',
message: 'The organization database has been 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) { } catch (error) {
next(error); next(error);
} }
@@ -144,7 +98,11 @@ export default class OrganizationController extends BaseController {
* @param {Response} res * @param {Response} res
* @param {NextFunction} next * @param {NextFunction} next
*/ */
async currentOrganization(req: Request, res: Response, next: NextFunction) { private async currentOrganization(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req; const { tenantId } = req;
try { try {
@@ -156,4 +114,68 @@ export default class OrganizationController extends BaseController {
next(error); 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);
}
} }

View File

@@ -11,7 +11,6 @@ import EnsureTenantIsInitialized from 'api/middleware/EnsureTenantIsInitialized'
import SettingsMiddleware from 'api/middleware/SettingsMiddleware'; import SettingsMiddleware from 'api/middleware/SettingsMiddleware';
import I18nMiddleware from 'api/middleware/I18nMiddleware'; import I18nMiddleware from 'api/middleware/I18nMiddleware';
import I18nAuthenticatedMiddlware from 'api/middleware/I18nAuthenticatedMiddlware'; import I18nAuthenticatedMiddlware from 'api/middleware/I18nAuthenticatedMiddlware';
import EnsureConfiguredMiddleware from 'api/middleware/EnsureConfiguredMiddleware';
import EnsureTenantIsSeeded from 'api/middleware/EnsureTenantIsSeeded'; import EnsureTenantIsSeeded from 'api/middleware/EnsureTenantIsSeeded';
// Routes // Routes
@@ -41,8 +40,8 @@ import Ping from 'api/controllers/Ping';
import Subscription from 'api/controllers/Subscription'; import Subscription from 'api/controllers/Subscription';
import Licenses from 'api/controllers/Subscription/Licenses'; import Licenses from 'api/controllers/Subscription/Licenses';
import InventoryAdjustments from 'api/controllers/Inventory/InventoryAdjustments'; import InventoryAdjustments from 'api/controllers/Inventory/InventoryAdjustments';
import Setup from 'api/controllers/Setup';
import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware';
import Jobs from './controllers/Jobs';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -59,7 +58,7 @@ export default () => {
app.use('/subscription', Container.get(Subscription).router()); app.use('/subscription', Container.get(Subscription).router());
app.use('/organization', Container.get(Organization).router()); app.use('/organization', Container.get(Organization).router());
app.use('/ping', Container.get(Ping).router()); app.use('/ping', Container.get(Ping).router());
app.use('/setup', Container.get(Setup).router()); app.use('/jobs', Container.get(Jobs).router());
// - Dashboard routes. // - Dashboard routes.
// --------------------------- // ---------------------------
@@ -72,7 +71,6 @@ export default () => {
dashboard.use(EnsureTenantIsInitialized); dashboard.use(EnsureTenantIsInitialized);
dashboard.use(SettingsMiddleware); dashboard.use(SettingsMiddleware);
dashboard.use(I18nAuthenticatedMiddlware); dashboard.use(I18nAuthenticatedMiddlware);
dashboard.use(EnsureConfiguredMiddleware);
dashboard.use(EnsureTenantIsSeeded); dashboard.use(EnsureTenantIsSeeded);
dashboard.use('/users', Container.get(Users).router()); dashboard.use('/users', Container.get(Users).router());

View File

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

View File

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

View File

@@ -7,4 +7,20 @@ export interface IOrganizationSetupDTO{
fiscalYear: string, fiscalYear: string,
industry: string, industry: string,
timeZone: 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;
} }

View File

@@ -6,6 +6,7 @@ export interface ITenant {
initializedAt: Date|null, initializedAt: Date|null,
seededAt: Date|null, seededAt: Date|null,
builtAt: Date|null,
createdAt: Date|null, createdAt: Date|null,
} }

View File

@@ -56,6 +56,7 @@ export * from './InventoryDetails';
export * from './LandedCost'; export * from './LandedCost';
export * from './Entry'; export * from './Entry';
export * from './TransactionsByReference'; export * from './TransactionsByReference';
export * from './Jobs';
export interface I18nService { export interface I18nService {
__: (input: string) => string; __: (input: string) => string;

View File

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

View File

@@ -3,7 +3,7 @@ import { isEmpty, isObject, isUndefined } from 'lodash';
export class Transformer { export class Transformer {
/** /**
* * Includeded attributes.
* @returns * @returns
*/ */
protected includeAttributes = (): string[] => { protected includeAttributes = (): string[] => {

View File

@@ -11,6 +11,7 @@ import SendSMSNotificationTrialEnd from 'jobs/SMSNotificationTrialEnd';
import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd'; import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd';
import SendMailNotificationTrialEnd from 'jobs/MailNotificationTrialEnd'; import SendMailNotificationTrialEnd from 'jobs/MailNotificationTrialEnd';
import UserInviteMailJob from 'jobs/UserInviteMail'; import UserInviteMailJob from 'jobs/UserInviteMail';
import OrganizationSetupJob from 'jobs/OrganizationSetup';
export default ({ agenda }: { agenda: Agenda }) => { export default ({ agenda }: { agenda: Agenda }) => {
new WelcomeEmailJob(agenda); new WelcomeEmailJob(agenda);
@@ -21,6 +22,7 @@ export default ({ agenda }: { agenda: Agenda }) => {
new SendLicenseViaPhoneJob(agenda); new SendLicenseViaPhoneJob(agenda);
new ComputeItemCost(agenda); new ComputeItemCost(agenda);
new RewriteInvoicesJournalEntries(agenda); new RewriteInvoicesJournalEntries(agenda);
new OrganizationSetupJob(agenda);
agenda.define( agenda.define(
'send-sms-notification-subscribe-end', 'send-sms-notification-subscribe-end',

View File

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

View File

@@ -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<IJobMeta>}
*/
async getJob(jobId: string): Promise<IJobMeta> {
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(),
};
}
}

View File

@@ -1,6 +1,8 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import { Container } from 'typedi';
// import { ObjectId } from 'mongoose';
import { ServiceError } from 'exceptions'; import { ServiceError } from 'exceptions';
import { ISystemService, ISystemUser, ITenant } from 'interfaces'; import { IOrganizationBuildDTO, ISystemUser, ITenant } from 'interfaces';
import { import {
EventDispatcher, EventDispatcher,
EventDispatcherInterface, EventDispatcherInterface,
@@ -12,12 +14,15 @@ import {
TenantDatabaseNotBuilt, TenantDatabaseNotBuilt,
} from 'exceptions'; } from 'exceptions';
import TenantsManager from 'services/Tenancy/TenantsManager'; import TenantsManager from 'services/Tenancy/TenantsManager';
import { Tenant, TenantMetadata } from 'system/models';
import { ObjectId } from 'mongodb';
const ERRORS = { const ERRORS = {
TENANT_NOT_FOUND: 'tenant_not_found', 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_ALREADY_SEEDED: 'tenant_already_seeded',
TENANT_DB_NOT_BUILT: 'tenant_db_not_built', TENANT_DB_NOT_BUILT: 'tenant_db_not_built',
TENANT_IS_BUILDING: 'TENANT_IS_BUILDING',
}; };
@Service() @Service()
@@ -34,92 +39,96 @@ export default class OrganizationService {
@Inject() @Inject()
tenantsManager: TenantsManager; tenantsManager: TenantsManager;
@Inject('agenda')
agenda: any;
/** /**
* Builds the database schema and seed data of the given organization id. * Builds the database schema and seed data of the given organization id.
* @param {srting} organizationId * @param {srting} organizationId
* @return {Promise<void>} * @return {Promise<void>}
*/ */
public async build(organizationId: string, user: ISystemUser): Promise<void> { public async build(tenantId: number): Promise<void> {
const tenant = await this.getTenantByOrgIdOrThrowError(organizationId); const tenant = await this.getTenantOrThrowError(tenantId);
// Throw error if the tenant is already initialized.
this.throwIfTenantInitizalized(tenant); this.throwIfTenantInitizalized(tenant);
const tenantHasDB = await this.tenantsManager.hasDatabase(tenant); // Drop the database if is already exists.
await this.tenantsManager.dropDatabaseIfExists(tenant);
try { // Creates a new database.
if (!tenantHasDB) { await this.tenantsManager.createDatabase(tenant);
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);
// Throws `onOrganizationBuild` event. // Migrate the tenant.
this.eventDispatcher.dispatch(events.organization.build, { tenant, user }); const migratedTenant = await this.tenantsManager.migrateTenant(tenant);
} catch (error) {
if (error instanceof TenantAlreadyInitialized) {
throw new ServiceError(ERRORS.TENANT_ALREADY_INITIALIZED);
} else {
throw error;
}
}
}
/** // Seed tenant.
* Seeds initial core data to the given organization tenant. const seededTenant = await this.tenantsManager.seedTenant(migratedTenant);
* @param {number} organizationId
* @return {Promise<void>}
*/
public async seed(organizationId: string): Promise<void> {
const tenant = await this.getTenantByOrgIdOrThrowError(organizationId);
this.throwIfTenantSeeded(tenant);
try { // Markes the tenant as completed builing.
this.logger.info('[organization] trying to seed tenant database.', { await Tenant.markAsBuilt(tenantId);
organizationId, await Tenant.markAsBuildCompleted(tenantId);
});
await this.tenantsManager.seedTenant(tenant);
// Throws `onOrganizationBuild` event. // Throws `onOrganizationBuild` event.
this.eventDispatcher.dispatch(events.organization.seeded, { tenant }); this.eventDispatcher.dispatch(events.organization.build, {
} catch (error) { tenant: seededTenant,
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<void>}
*/
public async listOrganizations(user: ISystemUser): Promise<ITenant[]> {
this.logger.info('[organization] trying to list all organizations.', {
user,
}); });
}
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. * Retrieve the current organization metadata.
* @param {number} tenantId * @param {number} tenantId
* @returns {Promise<ITenant[]>} * @returns {Promise<ITenant[]>}
*/ */
public async currentOrganization(tenantId: number): Promise<ITenant[]> { public async currentOrganization(tenantId: number): Promise<ITenant> {
const { tenantRepository } = this.sysRepositories; const tenant = await Tenant.query()
const tenant = await tenantRepository.findOneById(tenantId, ['subscriptions']); .findById(tenantId)
.withGraphFetched('subscriptions')
.withGraphFetched('metadata');
this.throwIfTenantNotExists(tenant); this.throwIfTenantNotExists(tenant);
@@ -132,7 +141,6 @@ export default class OrganizationService {
*/ */
private throwIfTenantNotExists(tenant: ITenant) { private throwIfTenantNotExists(tenant: ITenant) {
if (!tenant) { if (!tenant) {
this.logger.info('[tenant_db_build] organization id not found.');
throw new ServiceError(ERRORS.TENANT_NOT_FOUND); throw new ServiceError(ERRORS.TENANT_NOT_FOUND);
} }
} }
@@ -142,33 +150,34 @@ export default class OrganizationService {
* @param {ITenant} tenant * @param {ITenant} tenant
*/ */
private throwIfTenantInitizalized(tenant: ITenant) { private throwIfTenantInitizalized(tenant: ITenant) {
if (tenant.initializedAt) { if (tenant.builtAt) {
throw new ServiceError(ERRORS.TENANT_ALREADY_INITIALIZED); throw new ServiceError(ERRORS.TENANT_ALREADY_BUILT);
} }
} }
/** /**
* Throws service if the tenant already seeded. * Saves the organization metadata.
* @param {ITenant} tenant * @param tenant
* @param buildDTO
* @returns
*/ */
private throwIfTenantSeeded(tenant: ITenant) { private saveTenantMetadata(tenant: ITenant, buildDTO) {
if (tenant.seededAt) { return TenantMetadata.query().insert({
throw new ServiceError(ERRORS.TENANT_ALREADY_SEEDED); tenantId: tenant.id,
} ...buildDTO,
});
} }
/** /**
* Retrieve tenant model by the given organization id or throw not found * Retrieve tenant of throw not found error.
* error if the tenant not exists on the storage. * @param {number} tenantId -
* @param {string} organizationId
* @return {ITenant}
*/ */
private async getTenantByOrgIdOrThrowError(organizationId: string) { async getTenantOrThrowError(tenantId: number): Promise<ITenant> {
const { tenantRepository } = this.sysRepositories; const tenant = await Tenant.query().findById(tenantId);
const tenant = await tenantRepository.findOne({ organizationId });
this.throwIfTenantNotExists(tenant);
if (!tenant) {
throw new ServiceError(ERRORS.TENANT_NOT_FOUND);
}
return tenant; return tenant;
} }
} }

View File

@@ -7,8 +7,8 @@ import SystemService from 'services/Tenancy/SystemService';
import { TenantDBAlreadyExists } from 'exceptions'; import { TenantDBAlreadyExists } from 'exceptions';
import { tenantKnexConfig, tenantSeedConfig } from 'config/knexConfig'; import { tenantKnexConfig, tenantSeedConfig } from 'config/knexConfig';
export default class TenantDBManager implements ITenantDBManager{ export default class TenantDBManager implements ITenantDBManager {
static knexCache: { [key: string]: Knex; } = {}; static knexCache: { [key: string]: Knex } = {};
// System database manager. // System database manager.
dbManager: any; dbManager: any;
@@ -18,7 +18,7 @@ export default class TenantDBManager implements ITenantDBManager{
/** /**
* Constructor method. * Constructor method.
* @param {ITenant} tenant * @param {ITenant} tenant
*/ */
constructor() { constructor() {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
@@ -41,8 +41,12 @@ export default class TenantDBManager implements ITenantDBManager{
*/ */
public async databaseExists(tenant: ITenant) { public async databaseExists(tenant: ITenant) {
const databaseName = this.getDatabaseName(tenant); 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; return results[0].length > 0;
} }
@@ -59,6 +63,30 @@ export default class TenantDBManager implements ITenantDBManager{
await this.dbManager.createDb(databaseName); 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. * Migrate tenant database schema to the latest version.
* @return {Promise<void>} * @return {Promise<void>}
@@ -100,6 +128,9 @@ export default class TenantDBManager implements ITenantDBManager{
return knexInstance; return knexInstance;
} }
/**
* Retrieve knex instance from the givne tenant.
*/
public getKnexInstance(tenantId: number) { public getKnexInstance(tenantId: number) {
const key: string = `${tenantId}`; const key: string = `${tenantId}`;
let knexInstance = TenantDBManager.knexCache[key]; let knexInstance = TenantDBManager.knexCache[key];
@@ -120,4 +151,4 @@ export default class TenantDBManager implements ITenantDBManager{
throw new TenantDBAlreadyExists(); throw new TenantDBAlreadyExists();
} }
} }
} }

View File

@@ -1,26 +1,27 @@
import { Container, Inject, Service } from 'typedi'; import { Container, Inject, Service } from 'typedi';
import { ServiceError } from 'exceptions'; import { ServiceError } from 'exceptions';
import { import { ITenantManager, ITenant, ITenantDBManager } from 'interfaces';
ITenantManager,
ITenant,
ITenantDBManager,
} from 'interfaces';
import { import {
EventDispatcherInterface, EventDispatcherInterface,
EventDispatcher, EventDispatcher,
} from 'decorators/eventDispatcher'; } from 'decorators/eventDispatcher';
import { TenantAlreadyInitialized, TenantAlreadySeeded, TenantDatabaseNotBuilt } from 'exceptions'; import {
TenantAlreadyInitialized,
TenantAlreadySeeded,
TenantDatabaseNotBuilt,
} from 'exceptions';
import TenantDBManager from 'services/Tenancy/TenantDBManager'; import TenantDBManager from 'services/Tenancy/TenantDBManager';
import events from 'subscribers/events'; import events from 'subscribers/events';
import { Tenant } from 'system/models';
const ERRORS = { const ERRORS = {
TENANT_ALREADY_CREATED: 'TENANT_ALREADY_CREATED', TENANT_ALREADY_CREATED: 'TENANT_ALREADY_CREATED',
TENANT_NOT_EXISTS: 'TENANT_NOT_EXISTS' TENANT_NOT_EXISTS: 'TENANT_NOT_EXISTS',
}; };
// Tenants manager service. // Tenants manager service.
@Service() @Service()
export default class TenantsManagerService implements ITenantManager{ export default class TenantsManagerService implements ITenantManager {
static instances: { [key: number]: ITenantManager } = {}; static instances: { [key: number]: ITenantManager } = {};
@EventDispatcher() @EventDispatcher()
@@ -34,7 +35,7 @@ export default class TenantsManagerService implements ITenantManager{
/** /**
* Constructor method. * Constructor method.
*/ */
constructor() { constructor() {
this.tenantDBManager = new TenantDBManager(); this.tenantDBManager = new TenantDBManager();
} }
@@ -52,17 +53,26 @@ export default class TenantsManagerService implements ITenantManager{
/** /**
* Creates a new tenant database. * Creates a new tenant database.
* @param {ITenant} tenant - * @param {ITenant} tenant -
* @return {Promise<void>} * @return {Promise<void>}
*/ */
public async createDatabase(tenant: ITenant): Promise<void> { public async createDatabase(tenant: ITenant): Promise<void> {
this.throwErrorIfTenantAlreadyInitialized(tenant); this.throwErrorIfTenantAlreadyInitialized(tenant);
await this.tenantDBManager.createDatabase(tenant); await this.tenantDBManager.createDatabase(tenant);
this.eventDispatcher.dispatch(events.tenantManager.databaseCreated); 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. * Detarmines the tenant has database.
* @param {ITenant} tenant * @param {ITenant} tenant
@@ -77,15 +87,20 @@ export default class TenantsManagerService implements ITenantManager{
* @param {ITenant} tenant * @param {ITenant} tenant
* @return {Promise<void>} * @return {Promise<void>}
*/ */
public async migrateTenant(tenant: ITenant) { public async migrateTenant(tenant: ITenant): Promise<void> {
// Throw error if the tenant already initialized.
this.throwErrorIfTenantAlreadyInitialized(tenant); this.throwErrorIfTenantAlreadyInitialized(tenant);
const { tenantRepository } = this.sysRepositories; // Migrate the database tenant.
await this.tenantDBManager.migrate(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 * @param {ITenant} tenant
* @return {Promise<void>} * @return {Promise<void>}
*/ */
public async seedTenant(tenant: ITenant) { public async seedTenant(tenant: ITenant): Promise<void> {
// Throw error if the tenant is not built yet.
this.throwErrorIfTenantNotBuilt(tenant); 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. // Seed the tenant database.
await this.tenantDBManager.seed(tenant); await this.tenantDBManager.seed(tenant);
// Mark the tenant as seeded in specific date. // 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. * Throws error if the tenant database is not built yut.
* @param {ITenant} tenant * @param {ITenant} tenant
*/ */
private throwErrorIfTenantNotBuilt(tenant: ITenant) { private throwErrorIfTenantNotBuilt(tenant: ITenant) {
if (!tenant.initializedAt) { if (!tenant.initializedAt) {

View File

@@ -7,6 +7,9 @@ exports.up = function(knex) {
table.dateTime('under_maintenance_since').nullable(); table.dateTime('under_maintenance_since').nullable();
table.dateTime('initialized_at').nullable(); table.dateTime('initialized_at').nullable();
table.dateTime('seeded_at').nullable(); table.dateTime('seeded_at').nullable();
table.dateTime('built_at').nullable();
table.string('build_job_id');
table.timestamps(); table.timestamps();
}); });
}; };

View File

@@ -7,9 +7,6 @@ exports.up = function(knex) {
table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans');
table.bigInteger('tenant_id').unsigned().index().references('id').inTable('tenants'); 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('starts_at').nullable();
table.dateTime('ends_at').nullable(); table.dateTime('ends_at').nullable();

View File

@@ -1,6 +1,6 @@
exports.up = function(knex) { exports.up = function(knex) {
return knex.schema.createTable('subscription_licenses', table => { return knex.schema.createTable('subscription_licenses', (table) => {
table.increments(); table.increments();
table.string('license_code').unique().index(); table.string('license_code').unique().index();

View File

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

View File

@@ -1,6 +1,9 @@
import BaseModel from 'models/Model'; import moment from 'moment';
import { Model } from 'objection'; import { Model } from 'objection';
import uniqid from 'uniqid';
import SubscriptionPeriod from 'services/Subscription/SubscriptionPeriod'; import SubscriptionPeriod from 'services/Subscription/SubscriptionPeriod';
import BaseModel from 'models/Model';
import TenantMetadata from './TenantMetadata';
export default class Tenant extends BaseModel { export default class Tenant extends BaseModel {
/** /**
@@ -21,7 +24,7 @@ export default class Tenant extends BaseModel {
* Virtual attributes. * Virtual attributes.
*/ */
static get virtualAttributes() { static get virtualAttributes() {
return ['isReady']; return ['isReady', 'isBuildRunning'];
} }
/** /**
@@ -31,6 +34,13 @@ export default class Tenant extends BaseModel {
return !!(this.initializedAt && this.seededAt); return !!(this.initializedAt && this.seededAt);
} }
/**
* Detarimes the tenant whether is build currently running.
*/
get isBuildRunning() {
return !!this.buildJobId;
}
/** /**
* Query modifiers. * Query modifiers.
*/ */
@@ -47,6 +57,7 @@ export default class Tenant extends BaseModel {
*/ */
static get relationMappings() { static get relationMappings() {
const PlanSubscription = require('./Subscriptions/PlanSubscription'); const PlanSubscription = require('./Subscriptions/PlanSubscription');
const TenantMetadata = require('./TenantMetadata');
return { return {
subscriptions: { subscriptions: {
@@ -55,9 +66,17 @@ export default class Tenant extends BaseModel {
join: { join: {
from: 'tenants.id', from: 'tenants.id',
to: 'subscription_plan_subscriptions.tenantId', 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. * Records a new subscription for the associated tenant.
* @param {string} subscriptionSlug
* @param {IPlan} plan
*/ */
newSubscription(subscriptionSlug, plan) { newSubscription(subscriptionSlug, plan) {
const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod) const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod);
const period = new SubscriptionPeriod(plan.invoiceInterval, plan.invoicePeriod, trial.getEndDate()); const period = new SubscriptionPeriod(
plan.invoiceInterval,
plan.invoicePeriod,
trial.getEndDate()
);
return this.$relatedQuery('subscriptions').insert({ return this.$relatedQuery('subscriptions').insert({
slug: subscriptionSlug, slug: subscriptionSlug,
planId: plan.id, planId: plan.id,
trialStartedAt: trial.getStartDate(),
trialEndsAt: trial.getEndDate(),
startsAt: period.getStartDate(), startsAt: period.getStartDate(),
endsAt: period.getEndDate(), 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);
}
} }

View File

@@ -0,0 +1,10 @@
import BaseModel from 'models/Model';
export default class TenantMetadata extends BaseModel {
/**
* Table name.
*/
static get tableName() {
return 'tenants_metadata';
}
}

View File

@@ -4,6 +4,7 @@ import PlanFeature from './Subscriptions/PlanFeature';
import PlanSubscription from './Subscriptions/PlanSubscription'; import PlanSubscription from './Subscriptions/PlanSubscription';
import License from './Subscriptions/License'; import License from './Subscriptions/License';
import Tenant from './Tenant'; import Tenant from './Tenant';
import TenantMetadata from './TenantMetadata';
import SystemUser from './SystemUser'; import SystemUser from './SystemUser';
import PasswordReset from './PasswordReset'; import PasswordReset from './PasswordReset';
import Invite from './Invite'; import Invite from './Invite';
@@ -14,6 +15,7 @@ export {
PlanSubscription, PlanSubscription,
License, License,
Tenant, Tenant,
TenantMetadata,
SystemUser, SystemUser,
PasswordReset, PasswordReset,
Invite, Invite,

View File

@@ -6,18 +6,35 @@ exports.seed = (knex) => {
// Inserts seed entries // Inserts seed entries
return knex('subscription_plans').insert([ return knex('subscription_plans').insert([
{ {
name: 'Free', name: 'Essentials',
slug: 'free', slug: 'essentials-monthly',
price: 0, price: 100,
active: true, active: true,
currency: 'LYD', currency: 'LYD',
trial_period: 7, trial_period: 7,
trial_interval: 'days', trial_interval: 'days',
index: 1,
}, },
{ {
name: 'Starter', name: 'Essentials',
slug: 'starter', 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, price: 500,
active: true, active: true,
currency: 'LYD', currency: 'LYD',
@@ -26,14 +43,23 @@ exports.seed = (knex) => {
index: 2, index: 2,
}, },
{ {
name: 'Growth', name: 'Plus',
slug: 'growth', slug: 'plus-monthly',
price: 1000, price: 200,
active: true,
currency: 'LYD',
trial_period: 1,
trial_interval: 'months',
},
{
name: 'Plus',
slug: 'plus-yearly',
price: 500,
active: true, active: true,
currency: 'LYD', currency: 'LYD',
invoice_period: 12, invoice_period: 12,
invoice_interval: 'month', invoice_interval: 'month',
index: 3, index: 2,
}, },
]); ]);
}); });