diff --git a/packages/server/src/api/controllers/Export/ExportController.ts b/packages/server/src/api/controllers/Export/ExportController.ts new file mode 100644 index 000000000..12b5f938c --- /dev/null +++ b/packages/server/src/api/controllers/Export/ExportController.ts @@ -0,0 +1,100 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { query } from 'express-validator'; +import BaseController from '@/api/controllers/BaseController'; +import { ServiceError } from '@/exceptions'; +import { ExportApplication } from '@/services/Export/ExportApplication'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; + +@Service() +export class ExportController extends BaseController { + @Inject() + private exportResourceApp: ExportApplication; + + /** + * Router constructor method. + */ + router() { + const router = Router(); + + router.get( + '/', + [ + query('resource').exists(), + query('format').isIn(['csv', 'xlsx']).optional(), + ], + this.validationResult, + this.export.bind(this), + this.catchServiceErrors + ); + return router; + } + + /** + * Imports xlsx/csv to the given resource type. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + private async export(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const query = this.matchedQueryData(req); + + try { + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_PDF, + ]); + + const data = await this.exportResourceApp.export( + tenantId, + query.resource, + acceptType === ACCEPT_TYPE.APPLICATION_XLSX ? 'xlsx' : 'csv' + ); + if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(data); + // Retrieves the xlsx format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + res.setHeader( + 'Content-Disposition', + 'attachment; filename=output.xlsx' + ); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + return res.send(data); + } + } catch (error) { + next(error); + } + } + + /** + * Transforms service errors to response. + * @param {Error} + * @param {Request} req + * @param {Response} res + * @param {ServiceError} error + */ + private catchServiceErrors( + error, + req: Request, + res: Response, + next: NextFunction + ) { + if (error instanceof ServiceError) { + return res.status(400).send({ + errors: [{ type: error.errorType }], + }); + } + + next(error); + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 623e5a0f7..33b47e616 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -61,6 +61,7 @@ import { TaxRatesController } from './controllers/TaxRates/TaxRates'; import { ImportController } from './controllers/Import/ImportController'; import { BankingController } from './controllers/Banking/BankingController'; import { Webhooks } from './controllers/Webhooks/Webhooks'; +import { ExportController } from './controllers/Export/ExportController'; export default () => { const app = Router(); @@ -141,6 +142,7 @@ export default () => { dashboard.use('/projects', Container.get(ProjectsController).router()); dashboard.use('/tax-rates', Container.get(TaxRatesController).router()); dashboard.use('/import', Container.get(ImportController).router()); + dashboard.use('/export', Container.get(ExportController).router()) dashboard.use('/', Container.get(ProjectTasksController).router()); dashboard.use('/', Container.get(ProjectTimesController).router()); diff --git a/packages/server/src/services/Accounts/AccountsExportable.ts b/packages/server/src/services/Accounts/AccountsExportable.ts new file mode 100644 index 000000000..c3c24e352 --- /dev/null +++ b/packages/server/src/services/Accounts/AccountsExportable.ts @@ -0,0 +1,29 @@ +import { Inject, Service } from 'typedi'; +import { AccountsApplication } from './AccountsApplication'; +import { Exportable } from '../Export/Exportable'; +import { IAccountsFilter, IAccountsStructureType } from '@/interfaces'; + +@Service() +export class AccountsExportable extends Exportable { + @Inject() + private accountsApplication: AccountsApplication; + + /** + * Retrieves the accounts data to exportable sheet. + * @param {number} tenantId + * @returns + */ + public exportable(tenantId: number, query: IAccountsFilter) { + const parsedQuery = { + sortOrder: 'desc', + columnSortBy: 'created_at', + inactiveMode: false, + ...query, + structure: IAccountsStructureType.Flat, + } as IAccountsFilter; + + return this.accountsApplication + .getAccounts(tenantId, parsedQuery) + .then((output) => output.accounts); + } +} diff --git a/packages/server/src/services/Export/ExportApplication.ts b/packages/server/src/services/Export/ExportApplication.ts new file mode 100644 index 000000000..44a7dc73f --- /dev/null +++ b/packages/server/src/services/Export/ExportApplication.ts @@ -0,0 +1,17 @@ +import { Inject, Service } from 'typedi'; +import { ExportResourceService } from './ExportService'; + +@Service() +export class ExportApplication { + @Inject() + private exportResource: ExportResourceService; + + /** + * Exports the given resource to csv, xlsx or pdf format. + * @param {string} reosurce + * @param {string} format + */ + public export(tenantId: number, resource: string, format: string) { + return this.exportResource.export(tenantId, resource, format); + } +} diff --git a/packages/server/src/services/Export/ExportRegistery.ts b/packages/server/src/services/Export/ExportRegistery.ts new file mode 100644 index 000000000..33271f4ec --- /dev/null +++ b/packages/server/src/services/Export/ExportRegistery.ts @@ -0,0 +1,49 @@ +import { camelCase, upperFirst } from 'lodash'; +import { Exportable } from './Exportable'; + +export class ExportableRegistry { + private static instance: ExportableRegistry; + private exportables: Record; + + /** + * Constructor method. + */ + constructor() { + this.exportables = {}; + } + + /** + * Gets singleton instance of registry. + * @returns {ExportableRegistry} + */ + public static getInstance(): ExportableRegistry { + if (!ExportableRegistry.instance) { + ExportableRegistry.instance = new ExportableRegistry(); + } + return ExportableRegistry.instance; + } + + /** + * Registers the given importable service. + * @param {string} resource + * @param {Exportable} importable + */ + public registerExportable(resource: string, importable: Exportable): void { + const _resource = this.sanitizeResourceName(resource); + this.exportables[_resource] = importable; + } + + /** + * Retrieves the importable service instance of the given resource name. + * @param {string} name + * @returns {Exportable} + */ + public getExportable(name: string): Exportable { + const _name = this.sanitizeResourceName(name); + return this.exportables[_name]; + } + + private sanitizeResourceName(resource: string) { + return upperFirst(camelCase(resource)); + } +} diff --git a/packages/server/src/services/Export/ExportResources.ts b/packages/server/src/services/Export/ExportResources.ts new file mode 100644 index 000000000..f86f22589 --- /dev/null +++ b/packages/server/src/services/Export/ExportResources.ts @@ -0,0 +1,44 @@ +import Container, { Service } from 'typedi'; +import { AccountsExportable } from '../Accounts/AccountsExportable'; +import { ExportableRegistry } from './ExportRegistery'; +import { ItemsImportable } from '../Items/ItemsImportable'; +import { ItemsExportable } from '../Items/ItemsExportable'; + +@Service() +export class ExportableResources { + private static registry: ExportableRegistry; + + constructor() { + this.boot(); + } + + /** + * Importable instances. + */ + private importables = [ + { resource: 'Account', exportable: AccountsExportable }, + { resource: 'Item', exportable: ItemsExportable }, + ]; + + /** + * + */ + public get registry() { + return ExportableResources.registry; + } + + /** + * Boots all the registered importables. + */ + public boot() { + if (!ExportableResources.registry) { + const instance = ExportableRegistry.getInstance(); + + this.importables.forEach((importable) => { + const importableInstance = Container.get(importable.exportable); + instance.registerExportable(importable.resource, importableInstance); + }); + ExportableResources.registry = instance; + } + } +} diff --git a/packages/server/src/services/Export/ExportService.ts b/packages/server/src/services/Export/ExportService.ts new file mode 100644 index 000000000..0bd5c14df --- /dev/null +++ b/packages/server/src/services/Export/ExportService.ts @@ -0,0 +1,59 @@ +import { Inject, Service } from 'typedi'; +import xlsx from 'xlsx'; +import { sanitizeResourceName } from '../Import/_utils'; +import ResourceService from '../Resource/ResourceService'; +import { ExportableResources } from './ExportResources'; + +@Service() +export class ExportResourceService { + @Inject() + private resourceService: ResourceService; + + @Inject() + private exportableResources: ExportableResources; + + /** + * + * @param {number} tenantId + * @param {string} resourceName + * @param {string} format + */ + async export(tenantId: number, resourceName: string, format: string = 'csv') { + const resource = sanitizeResourceName(resourceName); + const resourceMeta = this.resourceService.getResourceMeta( + tenantId, + resource + ); + const exportable = + this.exportableResources.registry.getExportable(resource); + + const data = await exportable.exportable(tenantId, {}); + + const exportableColumns = [ + { + label: 'Account Normal', + accessor: 'accountNormalFormatted', + }, + { + label: 'Account Type', + accessor: 'accountTypeFormatted', + }, + ]; + + const workbook = xlsx.utils.book_new(); + const worksheetData = data.map((item) => + exportableColumns.map((col) => item[col.accessor]) + ); + worksheetData.unshift(exportableColumns.map((col) => col.label)); // Add header row + const worksheet = xlsx.utils.aoa_to_sheet(worksheetData); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Exported Data'); + + if (format.toLowerCase() === 'csv') { + // Convert to CSV using the xlsx package + return xlsx.write(workbook, { type: 'buffer', bookType: 'csv' }); + } else if (format.toLowerCase() === 'xlsx') { + // Write to XLSX format + return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + } + } +} diff --git a/packages/server/src/services/Export/Exportable.ts b/packages/server/src/services/Export/Exportable.ts new file mode 100644 index 000000000..0e8801678 --- /dev/null +++ b/packages/server/src/services/Export/Exportable.ts @@ -0,0 +1,22 @@ +export class Exportable { + /** + * + * @param tenantId + * @returns + */ + public async exportable( + tenantId: number, + query: Record + ): Promise>> { + return []; + } + + /** + * + * @param data + * @returns + */ + public transform(data: Record) { + return data; + } +} diff --git a/packages/server/src/services/Items/ItemsExportable.ts b/packages/server/src/services/Items/ItemsExportable.ts new file mode 100644 index 000000000..e25f37161 --- /dev/null +++ b/packages/server/src/services/Items/ItemsExportable.ts @@ -0,0 +1,23 @@ +import { Inject, Service } from 'typedi'; +import { Exportable } from '../Export/Exportable'; +import { IItemsFilter } from '@/interfaces'; +import { ItemsApplication } from './ItemsApplication'; + +@Service() +export class ItemsExportable extends Exportable { + @Inject() + private itemsApplication: ItemsApplication; + + /** + * Retrieves the accounts data to exportable sheet. + * @param {number} tenantId + * @returns + */ + public exportable(tenantId: number, query: IItemsFilter) { + const parsedQuery = {} as IItemsFilter; + + return this.itemsApplication + .getItems(tenantId, parsedQuery) + .then((output) => output.items); + } +}