This commit is contained in:
Ahmed Bouhuolia
2023-11-13 20:50:48 +02:00
parent 6634144d82
commit b75d44a3dd
23 changed files with 10151 additions and 5963 deletions

View File

@@ -5,6 +5,7 @@ global.__root_dir = path.join(__dirname, '..');
global.__resources_dir = path.join(global.__root_dir, 'resources');
global.__locales_dir = path.join(global.__resources_dir, 'locales');
global.__views_dir = path.join(global.__root_dir, 'views');
global.__storage_dir = path.join(global.__root_dir, 'storage');
moment.prototype.toMySqlDateTime = function () {
return this.format('YYYY-MM-DD HH:mm:ss');

View File

@@ -0,0 +1,14 @@
exports.up = function (knex) {
return knex.schema.createTable('storage', (table) => {
table.increments('id').primary();
table.string('key').notNullable();
table.string('path').notNullable();
table.string('extension').notNullable();
table.integer('expire_in');
table.timestamps();
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('storage');
};

View File

@@ -1,12 +1,15 @@
import { ChromiumRoute, LibreOfficeRoute, PdfEngineRoute } from './_types';
export class Chromiumly {
public static readonly GOTENBERG_ENDPOINT = '';
public static readonly GOTENBERG_ENDPOINT = process.env.GOTENBERG_URL || '';
public static readonly CHROMIUM_PATH = 'forms/chromium/convert';
public static readonly PDF_ENGINES_PATH = 'forms/pdfengines';
public static readonly LIBRE_OFFICE_PATH = 'forms/libreoffice';
public static readonly GOTENBERG_DOCS_ENDPOINT =
process.env.GOTENBERG_DOCS_URL || '';
public static readonly CHROMIUM_ROUTES = {
url: ChromiumRoute.URL,
html: ChromiumRoute.HTML,

View File

@@ -1,4 +1,5 @@
import { Chromiumly, ChromiumRoute } from '../../main.config';
import { Chromiumly } from './Chromiumly';
import { ChromiumRoute } from './_types';
export abstract class Converter {
readonly endpoint: string;

View File

@@ -1,5 +1,5 @@
import FormData from 'form-data';
import fetch from 'node-fetch';
import Axios from 'axios';
export class GotenbergUtils {
public static assert(condition: boolean, message: string): asserts condition {
@@ -9,17 +9,16 @@ export class GotenbergUtils {
}
public static async fetch(endpoint: string, data: FormData): Promise<Buffer> {
const response = await fetch(endpoint, {
method: 'post',
body: data,
headers: {
...data.getHeaders(),
},
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
try {
const response = await Axios.post(endpoint, data, {
headers: {
...data.getHeaders(),
},
responseType: 'arraybuffer', // This ensures you get a Buffer bac
});
return response.data;
} catch (error) {
console.error(error);
}
return response.buffer();
}
}

View File

@@ -1,4 +1,5 @@
import { json, Request, Response, NextFunction } from 'express';
import express from 'express';
import helmet from 'helmet';
import boom from 'express-boom';
import errorHandler from 'errorhandler';
@@ -42,6 +43,8 @@ export default ({ app }) => {
// Middleware for intercepting and transforming json responses.
app.use(JSONResponseTransformer(snakecaseResponseTransformer));
app.use('/public', express.static(path.join(global.__storage_dir)));
// Handle multi-media requests.
app.use(
fileUpload({

View File

@@ -60,6 +60,7 @@ import Time from 'models/Time';
import Task from 'models/Task';
import TaxRate from 'models/TaxRate';
import TaxRateTransaction from 'models/TaxRateTransaction';
import Attachment from 'models/Attachment';
export default (knex) => {
const models = {
@@ -123,6 +124,7 @@ export default (knex) => {
Task,
TaxRate,
TaxRateTransaction,
Attachment
};
return mapValues(models, (model) => model.bindKnex(knex));
};

View File

@@ -0,0 +1,23 @@
import { mixin } from 'objection';
import TenantModel from 'models/TenantModel';
import ModelSetting from './ModelSetting';
import ModelSearchable from './ModelSearchable';
export default class Attachment extends mixin(TenantModel, [
ModelSetting,
ModelSearchable,
]) {
/**
* Table name
*/
static get tableName() {
return 'storage';
}
/**
* Model timestamps.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
}

View File

@@ -0,0 +1,75 @@
import { Inject, Service } from 'typedi';
import path from 'path';
import { promises as fs } from 'fs';
import { PageProperties, PdfFormat } from '@/lib/Chromiumly/_types';
import { UrlConverter } from '@/lib/Chromiumly/UrlConvert';
import HasTenancyService from '../Tenancy/TenancyService';
import { Chromiumly } from '@/lib/Chromiumly/Chromiumly';
import { PDF_FILE_EXPIRE_IN, getPdfFilesStorageDir } from './utils';
@Service()
export class ChromiumlyHtmlConvert {
@Inject()
private tenancy: HasTenancyService;
/**
* Write HTML content to temporary file.
* @param {number} tenantId - Tenant id.
* @param {string} content - HTML content.
* @returns {Promise<[string, () => Promise<void>]>}
*/
async writeTempHtmlFile(
tenantId: number,
content: string
): Promise<[string, () => Promise<void>]> {
const { Attachment } = this.tenancy.models(tenantId);
const filename = `document-${Date.now()}.html`;
const storageDir = getPdfFilesStorageDir(filename);
const filePath = path.join(global.__storage_dir, storageDir);
await fs.writeFile(filePath, content);
await Attachment.query().insert({
key: filename,
path: storageDir,
expire_in: PDF_FILE_EXPIRE_IN, // ms
extension: 'html',
});
const cleanup = async () => {
await fs.unlink(filePath);
await Attachment.query().where('key', filename).delete();
};
return [filename, cleanup];
}
/**
* Converts the given HTML content to PDF.
* @param {string} html
* @param {PageProperties} properties
* @param {PdfFormat} pdfFormat
* @returns {Array<Buffer>}
*/
async convert(
tenantId: number,
html: string,
properties?: PageProperties,
pdfFormat?: PdfFormat
): Promise<Buffer> {
const [filename, cleanupTempFile] = await this.writeTempHtmlFile(
tenantId,
html
);
const fileDir = getPdfFilesStorageDir(filename);
const url = path.join(Chromiumly.GOTENBERG_DOCS_ENDPOINT, fileDir);
const urlConverter = new UrlConverter();
const buffer = await urlConverter.convert({
url,
properties,
pdfFormat,
});
await cleanupTempFile();
return buffer;
}
}

View File

@@ -0,0 +1,25 @@
import { Inject, Service } from 'typedi';
import { PageProperties, PdfFormat } from '@/lib/Chromiumly/_types';
import { ChromiumlyHtmlConvert } from './ChromiumlyHtmlConvert';
@Service()
export class ChromiumlyTenancy {
@Inject()
private htmlConvert: ChromiumlyHtmlConvert;
/**
* Converts the given HTML content to PDF.
* @param {string} content
* @param {PageProperties} properties
* @param {PdfFormat} pdfFormat
* @returns {Promise<Buffer>}
*/
public convertHtmlContent(
tenantId: number,
content: string,
properties?: PageProperties,
pdfFormat?: PdfFormat
) {
return this.htmlConvert.convert(tenantId, content, properties, pdfFormat);
}
}

View File

@@ -0,0 +1,8 @@
import path from 'path';
export const PDF_FILE_SUB_DIR = '/pdf';
export const PDF_FILE_EXPIRE_IN = 40; // ms
export const getPdfFilesStorageDir = (filename: string) => {
return path.join(PDF_FILE_SUB_DIR, filename);
}

View File

@@ -1,37 +1,29 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy';
import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable';
@Service()
export default class GetCreditNotePdf {
@Inject()
pdfService: PdfService;
private chromiumlyTenancy: ChromiumlyTenancy;
@Inject()
tenancy: HasTenancyService;
private templateInjectable: TemplateInjectable;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
*/
async getCreditNotePdf(tenantId: number, creditNote) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/credit-note-standard', {
organization,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
creditNote,
...i18n,
public async getCreditNotePdf(tenantId: number, creditNote) {
const htmlContent = await this.templateInjectable.render(
tenantId,
'modules/credit-note-standard',
{
creditNote,
}
);
return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, {
margins: { top: 0, bottom: 0, left: 0, right: 0 },
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -1,26 +0,0 @@
import { Service } from 'typedi';
import puppeteer from 'puppeteer';
import config from '@/config';
@Service()
export default class PdfService {
/**
* Pdf document.
* @param content
* @returns
*/
async pdfDocument(content: string) {
const browser = await puppeteer.connect({
browserWSEndpoint: config.puppeteer.browserWSEndpoint,
});
const page = await browser.newPage();
await page.setContent(content);
const pdf = await page.pdf({ format: 'a4' });
await browser.close();
return pdf;
}
}

View File

@@ -1,36 +1,29 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy';
import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable';
@Service()
export class SaleEstimatesPdf {
@Inject()
private pdfService: PdfService;
private chromiumlyTenancy: ChromiumlyTenancy;
@Inject()
private tenancy: HasTenancyService;
private templateInjectable: TemplateInjectable;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
*/
async getSaleEstimatePdf(tenantId: number, saleEstimate) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/estimate-regular', {
saleEstimate,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
...i18n,
const htmlContent = await this.templateInjectable.render(
tenantId,
'modules/estimate-regular',
{
saleEstimate,
}
);
return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, {
margins: { top: 0, bottom: 0, left: 0, right: 0 },
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -1,37 +1,35 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy';
import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable';
import { ISaleInvoice } from '@/interfaces';
@Service()
export class SaleInvoicePdf {
@Inject()
pdfService: PdfService;
private chromiumlyTenancy: ChromiumlyTenancy;
@Inject()
tenancy: HasTenancyService;
private templateInjectable: TemplateInjectable;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
* @param {number} tenantId - Tenant Id.
* @param {ISaleInvoice} saleInvoice -
* @returns {Promise<Buffer>}
*/
async saleInvoicePdf(tenantId: number, saleInvoice) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/invoice-regular', {
organization,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
saleInvoice,
...i18n,
async saleInvoicePdf(
tenantId: number,
saleInvoice: ISaleInvoice
): Promise<Buffer> {
const htmlContent = await this.templateInjectable.render(
tenantId,
'modules/invoice-regular',
{
saleInvoice,
}
);
return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, {
margins: { top: 0, bottom: 0, left: 0, right: 0 },
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -1,37 +1,35 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy';
import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable';
import { IPaymentReceive } from '@/interfaces';
@Service()
export default class GetPaymentReceivePdf {
@Inject()
private pdfService: PdfService;
private chromiumlyTenancy: ChromiumlyTenancy;
@Inject()
private tenancy: HasTenancyService;
private templateInjectable: TemplateInjectable;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
* @param {number} tenantId -
* @param {IPaymentReceive} paymentReceive -
* @returns {Promise<Buffer>}
*/
async getPaymentReceivePdf(tenantId: number, paymentReceive) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/payment-receive-standard', {
organization,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
paymentReceive,
...i18n,
async getPaymentReceivePdf(
tenantId: number,
paymentReceive: IPaymentReceive
): Promise<Buffer> {
const htmlContent = await this.templateInjectable.render(
tenantId,
'modules/payment-receive-standard',
{
paymentReceive,
}
);
return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, {
margins: { top: 0, bottom: 0, left: 0, right: 0 },
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -1,36 +1,29 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable';
import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy';
@Service()
export class SaleReceiptsPdf {
@Inject()
pdfService: PdfService;
private chromiumlyTenancy: ChromiumlyTenancy;
@Inject()
tenancy: HasTenancyService;
private templateInjectable: TemplateInjectable;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
*/
async saleReceiptPdf(tenantId: number, saleReceipt) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/receipt-regular', {
saleReceipt,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
...i18n,
public async saleReceiptPdf(tenantId: number, saleReceipt) {
const htmlContent = await this.templateInjectable.render(
tenantId,
'modules/receipt-regular',
{
saleReceipt,
}
);
return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, {
margins: { top: 0, bottom: 0, left: 0, right: 0 },
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -0,0 +1,35 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { templateRender } from '@/utils';
import { Tenant } from '@/system/models';
@Service()
export class TemplateInjectable {
@Inject()
private tenancy: HasTenancyService;
/**
* Renders the given filename of the template.
* @param {number} tenantId
* @param {string} filename
* @returns {string}
*/
public async render(
tenantId: number,
filename: string,
options: Record<string, string | number | boolean>
) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
return templateRender(filename, {
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
__: i18n.__,
...options
});
}
}

View File

@@ -372,7 +372,7 @@ const mergeObjectsBykey = (object1, object2, key) => {
};
function templateRender(filePath, options) {
const basePath = path.join(__dirname, '../../resources/views');
const basePath = path.join(global.__resources_dir, '/views');
return pug.renderFile(`${basePath}/${filePath}.pug`, options);
}