feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,46 @@
import { Response } from 'express';
import { convertAcceptFormatToFormat } from './_utils';
import { Controller, Headers, Query, Res } from '@nestjs/common';
import { ExportQuery } from './dtos/ExportQuery.dto';
import { ExportResourceService } from './ExportService';
import { AcceptType } from '@/constants/accept-type';
@Controller('/export')
export class ExportController {
constructor(private readonly exportResourceApp: ExportResourceService) {}
async export(
@Query() query: ExportQuery,
@Res() res: Response,
@Headers('accept') acceptHeader: string,
) {
const applicationFormat = convertAcceptFormatToFormat(acceptType);
const data = await this.exportResourceApp.export(
query.resource,
applicationFormat,
);
// Retrieves the csv format.
if (acceptHeader.includes(AcceptType.ApplicationCsv)) {
res.setHeader('Content-Disposition', 'attachment; filename=output.csv');
res.setHeader('Content-Type', 'text/csv');
return res.send(data);
// Retrieves the xlsx format.
} else if (acceptHeader.includes(AcceptType.ApplicationXlsx)) {
res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx');
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
return res.send(data);
// Retrieve the pdf format.
} else if (acceptHeader.includes(AcceptType.ApplicationPdf)) {
res.set({
'Content-Type': 'application/pdf',
'Content-Length': data.length,
});
res.send(data);
}
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ExportController } from './Export.controller';
import { ExportResourceService } from './ExportService';
import { ExportPdf } from './ExportPdf';
import { ExportAls } from './ExportAls';
import { ExportApplication } from './ExportApplication';
@Module({
providers: [ExportResourceService, ExportPdf, ExportAls, ExportApplication],
controllers: [ExportController],
})
export class ExportModule {}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
@Injectable()
export class ExportAls {
private als: AsyncLocalStorage<Map<string, any>>;
constructor() {
this.als = new AsyncLocalStorage();
}
/**
* Runs a callback function within the context of a new AsyncLocalStorage store.
* @param callback The function to be executed within the AsyncLocalStorage context.
* @returns The result of the callback function.
*/
public run<T>(callback: () => T): T {
return this.als.run<T>(new Map(), () => {
this.markAsExport();
return callback();
});
}
/**
* Retrieves the current AsyncLocalStorage store.
* @returns The current store or undefined if not in a valid context.
*/
public getStore(): Map<string, any> | undefined {
return this.als.getStore();
}
/**
* Marks the current context as an export operation.
* @param flag Boolean flag to set or unset the export status. Defaults to true.
*/
public markAsExport(flag: boolean = true): void {
const store = this.getStore();
store?.set('isExport', flag);
}
/**
* Checks if the current context is an export operation.
* @returns {boolean} True if the context is an export operation, false otherwise.
*/
public get isExport(): boolean {
return !!this.getStore()?.get('isExport');
}
}

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { ExportResourceService } from './ExportService';
import { ExportFormat } from './common';
@Injectable()
export class ExportApplication {
/**
* Constructor method.
*/
constructor(
private readonly exportResource: ExportResourceService,
) {}
/**
* Exports the given resource to csv, xlsx or pdf format.
* @param {string} reosurce
* @param {ExportFormat} format
*/
public export(resource: string, format: ExportFormat) {
return this.exportResource.export(resource, format);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy.service';
import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable.service';
import { mapPdfRows } from './utils';
@Injectable()
export class ExportPdf {
constructor(
private readonly templateInjectable: TemplateInjectable,
private readonly chromiumlyTenancy: ChromiumlyTenancy,
) {}
/**
* Generates the pdf table sheet for the given data and columns.
* @param {} columns
* @param {Record<string, string>} data
* @param {string} sheetTitle
* @param {string} sheetDescription
* @returns
*/
public async pdf(
columns: { accessor: string },
data: Record<string, any>,
sheetTitle: string = '',
sheetDescription: string = ''
) {
const rows = mapPdfRows(columns, data);
const htmlContent = await this.templateInjectable.render(
'modules/export-resource-table',
{
table: { rows, columns },
sheetTitle,
sheetDescription,
}
);
// Convert the HTML content to PDF
return this.chromiumlyTenancy.convertHtmlContent(htmlContent, {
margins: { top: 0.2, bottom: 0.2, left: 0.2, right: 0.2 },
landscape: true,
});
}
}

View File

@@ -0,0 +1,50 @@
// @ts-nocheck
import { camelCase, upperFirst } from 'lodash';
import { Exportable } from './Exportable';
export class ExportableRegistry {
private static instance: ExportableRegistry;
private exportables: Record<string, Exportable>;
/**
* 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));
}
}

View File

@@ -0,0 +1,75 @@
// import Container, { Service } from 'typedi';
// import { AccountsExportable } from '../Accounts/AccountsExportable';
// import { ExportableRegistry } from './ExportRegistery';
// import { ItemsExportable } from '../Items/ItemsExportable';
// import { CustomersExportable } from '../Contacts/Customers/CustomersExportable';
// import { VendorsExportable } from '../Contacts/Vendors/VendorsExportable';
// import { ExpensesExportable } from '../Expenses/ExpensesExportable';
// import { SaleInvoicesExportable } from '../Sales/Invoices/SaleInvoicesExportable';
// import { SaleEstimatesExportable } from '../Sales/Estimates/SaleEstimatesExportable';
// import { SaleReceiptsExportable } from '../Sales/Receipts/SaleReceiptsExportable';
// import { BillsExportable } from '../Purchases/Bills/BillsExportable';
// import { PaymentsReceivedExportable } from '../Sales/PaymentReceived/PaymentsReceivedExportable';
// import { BillPaymentExportable } from '../Purchases/BillPayments/BillPaymentExportable';
// import { ManualJournalsExportable } from '../ManualJournals/ManualJournalExportable';
// import { CreditNotesExportable } from '../CreditNotes/CreditNotesExportable';
// import { VendorCreditsExportable } from '../Purchases/VendorCredits/VendorCreditsExportable';
// import { ItemCategoriesExportable } from '../ItemCategories/ItemCategoriesExportable';
// import { TaxRatesExportable } from '../TaxRates/TaxRatesExportable';
import { Injectable } from "@nestjs/common";
import { ExportableRegistry } from "./ExportRegistery";
import { AccountsExportable } from "../Accounts/AccountsExportable.service";
@Injectable()
export class ExportableResources {
constructor(
private readonly exportRegistry: ExportableRegistry,
) {
this.boot();
}
/**
* Importable instances.
*/
private importables = [
{ resource: 'Account', exportable: AccountsExportable },
// { resource: 'Item', exportable: ItemsExportable },
// { resource: 'ItemCategory', exportable: ItemCategoriesExportable },
// { resource: 'Customer', exportable: CustomersExportable },
// { resource: 'Vendor', exportable: VendorsExportable },
// { resource: 'Expense', exportable: ExpensesExportable },
// { resource: 'SaleInvoice', exportable: SaleInvoicesExportable },
// { resource: 'SaleEstimate', exportable: SaleEstimatesExportable },
// { resource: 'SaleReceipt', exportable: SaleReceiptsExportable },
// { resource: 'Bill', exportable: BillsExportable },
// { resource: 'PaymentReceive', exportable: PaymentsReceivedExportable },
// { resource: 'BillPayment', exportable: BillPaymentExportable },
// { resource: 'ManualJournal', exportable: ManualJournalsExportable },
// { resource: 'CreditNote', exportable: CreditNotesExportable },
// { resource: 'VendorCredit', exportable: VendorCreditsExportable },
// { resource: 'TaxRate', exportable: TaxRatesExportable },
];
/**
*
*/
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;
}
}
}

View File

@@ -0,0 +1,221 @@
// @ts-nocheck
import xlsx from 'xlsx';
import * as R from 'ramda';
import { get } from 'lodash';
import { sanitizeResourceName } from '../Import/_utils';
import { ExportableResources } from './ExportResources';
import { ServiceError } from '@/exceptions';
import { Errors, ExportFormat } from './common';
import { IModelMeta, IModelMetaColumn } from '@/interfaces';
import { flatDataCollections, getDataAccessor } from './utils';
import { ExportPdf } from './ExportPdf';
import { ExportAls } from './ExportAls';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportResourceService {
constructor(
private readonly exportAls: ExportAls,
private readonly exportPdf: ExportPdf,
private readonly exportableResources: ExportableResources,
private readonly resourceService: ResourceService,
) {}
/**
*
* @param {string} resourceName
* @param {ExportFormat} format
* @returns
*/
public async export(
resourceName: string,
format: ExportFormat = ExportFormat.Csv
) {
return this.exportAls.run(() =>
this.exportAlsRun(resourceName, format)
);
}
/**
* Exports the given resource data through csv, xlsx or pdf.
* @param {string} resourceName - Resource name.
* @param {ExportFormat} format - File format.
*/
public async exportAlsRun(
resourceName: string,
format: ExportFormat = ExportFormat.Csv
) {
const resource = sanitizeResourceName(resourceName);
const resourceMeta = this.getResourceMeta(tenantId, resource);
const resourceColumns = this.resourceService.getResourceColumns(
tenantId,
resource
);
this.validateResourceMeta(resourceMeta);
const data = await this.getExportableData(tenantId, resource);
const transformed = this.transformExportedData(tenantId, resource, data);
// Returns the csv, xlsx format.
if (format === ExportFormat.Csv || format === ExportFormat.Xlsx) {
const exportableColumns = this.getExportableColumns(resourceColumns);
const workbook = this.createWorkbook(transformed, exportableColumns);
return this.exportWorkbook(workbook, format);
// Returns the pdf format.
} else if (format === ExportFormat.Pdf) {
const printableColumns = this.getPrintableColumns(resourceMeta);
return this.exportPdf.pdf(
tenantId,
printableColumns,
transformed,
resourceMeta?.print?.pageTitle
);
}
}
/**
* Retrieves metadata for a specific resource.
* @param {number} tenantId - The tenant identifier.
* @param {string} resource - The name of the resource.
* @returns The metadata of the resource.
*/
private getResourceMeta(resource: string) {
return this.resourceService.getResourceMeta(resource);
}
/**
* Validates if the resource metadata is exportable.
* @param {any} resourceMeta - The metadata of the resource.
* @throws {ServiceError} If the resource is not exportable or lacks columns.
*/
private validateResourceMeta(resourceMeta: any) {
if (!resourceMeta.exportable || !resourceMeta.columns) {
throw new ServiceError(Errors.RESOURCE_NOT_EXPORTABLE);
}
}
/**
* Transforms the exported data based on the resource metadata.
* If the resource metadata specifies a flattening attribute (`exportFlattenOn`),
* the data will be flattened based on this attribute using the `flatDataCollections` utility function.
* @param {string} resource - The name of the resource.
* @param {Array<Record<string, any>>} data - The original data to be transformed.
* @returns {Array<Record<string, any>>} - The transformed data.
*/
private transformExportedData(
resource: string,
data: Array<Record<string, any>>
): Array<Record<string, any>> {
const resourceMeta = this.getResourceMeta(resource);
return R.when<Array<Record<string, any>>, Array<Record<string, any>>>(
R.always(Boolean(resourceMeta.exportFlattenOn)),
(data) => flatDataCollections(data, resourceMeta.exportFlattenOn),
data
);
}
/**
* Fetches exportable data for a given resource.
* @param {number} tenantId - The tenant identifier.
* @param {string} resource - The name of the resource.
* @returns A promise that resolves to the exportable data.
*/
private async getExportableData(resource: string) {
const exportable =
this.exportableResources.registry.getExportable(resource);
return exportable.exportable({});
}
/**
* Extracts columns that are marked as exportable from the resource metadata.
* @param {IModelMeta} resourceMeta - The metadata of the resource.
* @returns An array of exportable columns.
*/
private getExportableColumns(resourceColumns: any) {
const processColumns = (
columns: { [key: string]: IModelMetaColumn },
parent = ''
) => {
return Object.entries(columns)
.filter(([_, value]) => value.exportable !== false)
.flatMap(([key, value]) => {
if (value.type === 'collection' && value.collectionOf === 'object') {
return processColumns(value.columns, key);
} else {
const group = parent;
return [
{
name: value.name,
type: value.type || 'text',
accessor: value.accessor || key,
group,
},
];
}
});
};
return processColumns(resourceColumns);
}
private getPrintableColumns(resourceMeta: IModelMeta) {
const processColumns = (
columns: { [key: string]: IModelMetaColumn },
parent = ''
) => {
return Object.entries(columns)
.filter(([_, value]) => value.printable !== false)
.flatMap(([key, value]) => {
if (value.type === 'collection' && value.collectionOf === 'object') {
return processColumns(value.columns, key);
} else {
const group = parent;
return [
{
name: value.name,
type: value.type || 'text',
accessor: value.accessor || key,
group,
},
];
}
});
};
return processColumns(resourceMeta.columns);
}
/**
* Creates a workbook from the provided data and columns.
* @param {any[]} data - The data to be included in the workbook.
* @param {any[]} exportableColumns - The columns to be included in the workbook.
* @returns The created workbook.
*/
private createWorkbook(data: any[], exportableColumns: any[]) {
const workbook = xlsx.utils.book_new();
const worksheetData = data.map((item) =>
exportableColumns.map((col) => get(item, getDataAccessor(col)))
);
worksheetData.unshift(exportableColumns.map((col) => col.name));
const worksheet = xlsx.utils.aoa_to_sheet(worksheetData);
xlsx.utils.book_append_sheet(workbook, worksheet, 'Exported Data');
return workbook;
}
/**
* Exports the workbook in the specified format.
* @param {any} workbook - The workbook to be exported.
* @param {string} format - The format to export the workbook in.
* @returns The exported workbook data.
*/
private exportWorkbook(workbook: any, format: string) {
if (format.toLowerCase() === 'csv') {
return xlsx.write(workbook, { type: 'buffer', bookType: 'csv' });
} else if (format.toLowerCase() === 'xlsx') {
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
}
}

View File

@@ -0,0 +1,21 @@
export class Exportable {
/**
*
* @param tenantId
* @returns
*/
public async exportable(
query: Record<string, any>,
): Promise<Array<Record<string, any>>> {
return [];
}
/**
*
* @param data
* @returns
*/
public transform(data: Record<string, any>) {
return data;
}
}

View File

@@ -0,0 +1,9 @@
export enum Errors {
RESOURCE_NOT_EXPORTABLE = 'RESOURCE_NOT_EXPORTABLE',
}
export enum ExportFormat {
Csv = 'csv',
Pdf = 'pdf',
Xlsx = 'xlsx',
}

View File

@@ -0,0 +1,2 @@
export const EXPORT_SIZE_LIMIT = 9999999;
export const EXPORT_DTE_FORMAT = 'YYYY-MM-DD';

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class ExportQuery {
@IsString()
@IsNotEmpty()
resource: string;
@IsString()
@IsNotEmpty()
format: string;
}

View File

@@ -0,0 +1,45 @@
import { flatMap, get } from 'lodash';
/**
* Flattens the data based on a specified attribute.
* @param data - The data to be flattened.
* @param flattenAttr - The attribute to be flattened.
* @returns - The flattened data.
*/
export const flatDataCollections = (
data: Record<string, any>,
flattenAttr: string
): Record<string, any>[] => {
return flatMap(data, (item) =>
item[flattenAttr].map((entry) => ({
...item,
[flattenAttr]: entry,
}))
);
};
/**
* Gets the data accessor for a given column.
* @param col - The column to get the data accessor for.
* @returns - The data accessor.
*/
export const getDataAccessor = (col: any) => {
return col.group ? `${col.group}.${col.accessor}` : col.accessor;
};
/**
* Maps the data retrieved from the service layer to the pdf document.
* @param {any} columns
* @param {Record<stringm any>} data
* @returns
*/
export const mapPdfRows = (columns: any, data: Record<string, any>) => {
return data.map((item) => {
const cells = columns.map((column) => {
return {
key: column.accessor,
value: get(item, getDataAccessor(column)),
};
});
return { cells, classNames: '' };
});
};