refactor: mail templates

This commit is contained in:
Ahmed Bouhuolia
2025-06-08 16:49:03 +02:00
parent 0a57b6e20e
commit 4366bf478a
44 changed files with 1866 additions and 134 deletions

View File

@@ -18,6 +18,7 @@ import {
CreateSaleEstimateDto,
EditSaleEstimateDto,
} from './dtos/SaleEstimate.dto';
import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service';
@Injectable()
export class SaleEstimatesApplication {
@@ -33,6 +34,7 @@ export class SaleEstimatesApplication {
private readonly sendEstimateMailService: SendSaleEstimateMail,
private readonly getSaleEstimateStateService: GetSaleEstimateState,
private readonly saleEstimatesPdfService: GetSaleEstimatePdf,
private readonly getSaleEstimateMailStateService: GetSaleEstimateMailStateService,
) {}
/**
@@ -172,4 +174,23 @@ export class SaleEstimatesApplication {
public getSaleEstimateState() {
return this.getSaleEstimateStateService.getSaleEstimateState();
}
/**
* Retrieves the sale estimate mail state.
* @param {number} saleEstimateId
* @returns {Promise<SaleEstimateMailOptions>}
*/
public getSaleEstimateMailState(saleEstimateId: number) {
return this.getSaleEstimateMailStateService.getEstimateMailState(
saleEstimateId,
);
}
/**
* Retrieve the HTML content of the given sale estimate.
* @param {number} saleEstimateId
*/
public getSaleEstimateHtml(saleEstimateId: number) {
return this.saleEstimatesPdfService.saleEstimateHtml(saleEstimateId);
}
}

View File

@@ -208,7 +208,7 @@ export class SaleEstimatesController {
}
@Get(':id/mail')
@ApiOperation({ summary: 'Retrieves the sale estimate mail details.' })
@ApiOperation({ summary: 'Retrieves the sale estimate mail state.' })
@ApiParam({
name: 'id',
required: true,
@@ -218,7 +218,9 @@ export class SaleEstimatesController {
public getSaleEstimateMail(
@Param('id', ParseIntPipe) saleEstimateId: number,
) {
return this.saleEstimatesApplication.getSaleEstimateMail(saleEstimateId);
return this.saleEstimatesApplication.getSaleEstimateMailState(
saleEstimateId,
);
}
@Get(':id')
@@ -243,6 +245,11 @@ export class SaleEstimatesController {
'Content-Length': pdfContent.length,
});
res.send(pdfContent);
} else if (acceptHeader.includes(AcceptType.ApplicationTextHtml)) {
const htmlContent =
await this.saleEstimatesApplication.getSaleEstimateHtml(estimateId);
return { htmlContent };
} else {
return this.saleEstimatesApplication.getSaleEstimate(estimateId);
}

View File

@@ -37,6 +37,8 @@ import { PdfTemplatesModule } from '../PdfTemplate/PdfTemplates.module';
import { SendSaleEstimateMailQueue } from './types/SaleEstimates.types';
import { SaleEstimatesExportable } from './SaleEstimatesExportable';
import { SaleEstimatesImportable } from './SaleEstimatesImportable';
import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service';
import { GetSaleEstimateMailTemplateService } from './queries/GetSaleEstimateMailTemplate.service';
@Module({
imports: [
@@ -78,11 +80,15 @@ import { SaleEstimatesImportable } from './SaleEstimatesImportable';
GetSaleEstimatePdf,
SaleEstimatePdfTemplate,
SaleEstimatesExportable,
SaleEstimatesImportable
SaleEstimatesImportable,
GetSaleEstimateMailStateService,
GetSaleEstimateMailTemplateService
],
exports: [
SaleEstimatesExportable,
SaleEstimatesImportable
SaleEstimatesImportable,
GetSaleEstimateMailStateService,
GetSaleEstimateMailTemplateService
]
})
export class SaleEstimatesModule {}

View File

@@ -23,6 +23,7 @@ import { SaleEstimateMailOptions } from '../types/SaleEstimates.types';
import { Mail } from '@/modules/Mail/Mail';
import { MailTransporter } from '@/modules/Mail/MailTransporter.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetSaleEstimateMailTemplateService } from '../queries/GetSaleEstimateMailTemplate.service';
@Injectable()
export class SendSaleEstimateMail {
@@ -38,6 +39,7 @@ export class SendSaleEstimateMail {
private readonly estimatePdf: GetSaleEstimatePdf,
private readonly getSaleEstimateService: GetSaleEstimate,
private readonly contactMailNotification: ContactMailNotification,
private readonly getEstimateMailTemplate: GetSaleEstimateMailTemplateService,
private readonly eventPublisher: EventEmitter2,
private readonly mailTransporter: MailTransporter,
@@ -78,7 +80,12 @@ export class SendSaleEstimateMail {
*/
public formatterArgs = async (estimateId: number) => {
const estimate = await this.getSaleEstimateService.getEstimate(estimateId);
return transformEstimateToMailDataArgs(estimate);
const commonArgs = await this.contactMailNotification.getCommonFormatArgs();
return {
...commonArgs,
...transformEstimateToMailDataArgs(estimate),
};
};
/**
@@ -129,9 +136,35 @@ export class SendSaleEstimateMail {
mailOptions,
formatterArgs,
);
return { ...formattedOptions };
// Retrieves the estimate mail template.
const message = await this.getEstimateMailTemplate.getMailTemplate(
saleEstimateId,
{
message: formattedOptions.message,
preview: formattedOptions.message,
},
);
return { ...formattedOptions, message };
};
/**
* Retrieves the formatted mail options.
* @param {number} saleEstimateId
* @param {SaleEstimateMailOptionsDTO} messageOptions
* @returns {Promise<SaleEstimateMailOptions>}
*/
public async getFormattedMailOptions(
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO,
): Promise<SaleEstimateMailOptions> {
const defaultMessageOptions = await this.getMailOptions(saleEstimateId);
const parsedMessageOptions = mergeAndValidateMailOptions(
defaultMessageOptions,
messageOptions,
);
return this.formatMailOptions(saleEstimateId, parsedMessageOptions);
}
/**
* Sends the mail notification of the given sale estimate.
* @param {number} saleEstimateId - Sale estimate id.
@@ -142,16 +175,9 @@ export class SendSaleEstimateMail {
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO,
): Promise<void> {
const localMessageOpts = await this.getMailOptions(saleEstimateId);
// Overrides and validates the given mail options.
const parsedMessageOptions = mergeAndValidateMailOptions(
localMessageOpts,
messageOptions,
) as SaleEstimateMailOptions;
const formattedOptions = await this.formatMailOptions(
const formattedOptions = await this.getFormattedMailOptions(
saleEstimateId,
parsedMessageOptions,
messageOptions,
);
const mail = new Mail()
.setSubject(formattedOptions.subject)
@@ -173,7 +199,6 @@ export class SendSaleEstimateMail {
},
]);
}
const eventPayload = {
saleEstimateId,
messageOptions,

View File

@@ -1,18 +1,17 @@
export const DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT =
'Estimate {Estimate Number} is awaiting your approval';
export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `<p>Dear {Customer Name}</p>
<p>Thank you for your business, You can view or print your estimate from attachements.</p>
<p>
Estimate <strong>#{Estimate Number}</strong><br />
Expiration Date : <strong>{Estimate Expiration Date}</strong><br />
Amount : <strong>{Estimate Amount}</strong></br />
</p>
export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `Hi {Customer Name},
<p>
<i>Regards</i><br />
<i>{Company Name}</i>
</p>
`;
Here's estimate # {Estimate Number} for {Estimate Amount}
This estimate is valid until {Estimate Expiration Date}, and were happy to discuss any adjustments you or questions may have.
Please find your estimate attached to this email for your reference.
If you have any questions, please let us know.
Thanks,
{Company Name}`;
export const ERRORS = {
SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND',

View File

@@ -1,6 +1,5 @@
import * as moment from 'moment';
import { Model } from 'objection';
import { Injectable } from '@nestjs/common';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator';
import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
@@ -307,12 +306,6 @@ export class SaleEstimate extends TenantBaseModel {
},
};
}
/**
* Model settings.
*/
// static get meta() {
// return SaleEstimateSettings;
// }
/**
* Retrieve the default custom views, roles and columns.

View File

@@ -0,0 +1,167 @@
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetEstimateMailTemplateAttributesTransformer extends Transformer {
public includeAttributes = (): string[] => {
return [
'companyLogoUri',
'companyName',
'estimateAmount',
'primaryColor',
'estimateAmount',
'estimateMessage',
'dueDate',
'dueDateLabel',
'estimateNumber',
'estimateNumberLabel',
'total',
'totalLabel',
'subtotal',
'subtotalLabel',
'dueAmount',
'dueAmountLabel',
'viewEstimateButtonLabel',
'viewEstimateButtonUrl',
'items',
];
};
/**
* Exclude all attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Company logo uri.
* @returns {string}
*/
public companyLogoUri(): string {
return this.options.brandingTemplate?.companyLogoUri;
}
/**
* Company name.
* @returns {string}
*/
public companyName(): string {
return this.context.organization.name;
}
/**
* Primary color.
* @returns {string}
*/
public primaryColor(): string {
return this.options?.brandingTemplate?.attributes?.primaryColor;
}
/**
* Estimate number.
* @returns {string}
*/
public estimateNumber(): string {
return this.options.estimate.estimateNumber;
}
/**
* Estimate number label.
* @returns {string}
*/
public estimateNumberLabel(): string {
return 'Estimate No: {estimateNumber}';
}
/**
* Expiration date.
* @returns {string}
*/
public expirationDate(): string {
return this.options.estimate.formattedExpirationDate;
}
/**
* Expiration date label.
* @returns {string}
*/
public expirationDateLabel(): string {
return 'Expiration Date: {expirationDate}';
}
/**
* Estimate total.
*/
public total(): string {
return this.options.estimate.formattedAmount;
}
/**
* Estimate total label.
* @returns {string}
*/
public totalLabel(): string {
return 'Total';
}
/**
* Estimate subtotal.
*/
public subtotal(): string {
return this.options.estimate.formattedAmount;
}
/**
* Estimate subtotal label.
* @returns {string}
*/
public subtotalLabel(): string {
return 'Subtotal';
}
/**
* Estimate mail items attributes.
*/
public items(): any[] {
return this.item(
this.options.estimate.entries,
new GetEstimateMailTemplateEntryAttributesTransformer(),
);
}
}
class GetEstimateMailTemplateEntryAttributesTransformer extends Transformer {
public includeAttributes = (): string[] => {
return ['label', 'quantity', 'rate', 'total'];
};
public excludeAttributes = (): string[] => {
return ['*'];
};
public label(entry): string {
return entry?.item?.name;
}
public quantity(entry): string {
return entry?.quantity;
}
public rate(entry): string {
return entry?.rateFormatted;
}
public total(entry): string {
return entry?.totalFormatted;
}
}

View File

@@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { SaleEstimate } from '../models/SaleEstimate';
import { SendSaleEstimateMail } from '../commands/SendSaleEstimateMail';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { GetSaleEstimateMailStateTransformer } from './GetSaleEstimateMailState.transformer';
@Injectable()
export class GetSaleEstimateMailStateService {
constructor(
private readonly estimateMail: SendSaleEstimateMail,
private readonly transformer: TransformerInjectable,
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>
) {}
/**
* Retrieves the estimate mail state of the given sale estimate.
* Estimate mail state includes the mail options, branding attributes and the estimate details.
* @param {number} saleEstimateId
* @returns {Promise<SaleEstimateMailState>}
*/
async getEstimateMailState(
saleEstimateId: number
) {
const saleEstimate = await this.saleEstimateModel().query()
.findById(saleEstimateId)
.withGraphFetched('customer')
.withGraphFetched('entries.item')
.withGraphFetched('pdfTemplate')
.throwIfNotFound();
const mailOptions = await this.estimateMail.getMailOptions(
saleEstimateId
);
const transformed = await this.transformer.transform(
saleEstimate,
new GetSaleEstimateMailStateTransformer(),
{
mailOptions,
}
);
return transformed;
}
}

View File

@@ -0,0 +1,171 @@
import { ItemEntryTransformer } from '@/modules/TransactionItemEntry/ItemEntry.transformer';
import { SaleEstimateTransfromer } from './SaleEstimate.transformer';
export class GetSaleEstimateMailStateTransformer extends SaleEstimateTransfromer {
public excludeAttributes = (): string[] => {
return ['*'];
};
public includeAttributes = (): string[] => {
return [
'estimateDate',
'estimateDateFormatted',
'expirationDate',
'expirationDateFormatted',
'total',
'totalFormatted',
'subtotal',
'subtotalFormatted',
'estimateNumber',
'entries',
'companyName',
'companyLogoUri',
'primaryColor',
'customerName',
];
};
/**
* Retrieves the customer name of the invoice.
* @returns {string}
*/
protected customerName = (invoice) => {
return invoice.customer.displayName;
};
/**
* Retrieves the company name.
* @returns {string}
*/
protected companyName = () => {
return this.context.organization.name;
};
/**
* Retrieves the company logo uri.
* @returns {string | null}
*/
protected companyLogoUri = (invoice) => {
return invoice.pdfTemplate?.companyLogoUri || null;
};
/**
* Retrieves the primary color.
* @returns {string}
*/
protected primaryColor = (invoice) => {
return invoice.pdfTemplate?.attributes?.primaryColor || null;
};
/**
* Retrieves the estimate number.
*/
protected estimateDateFormatted = (estimate) => {
return this.formattedEstimateDate(estimate);
};
/**
* Retrieves the expiration date of the estimate.
* @param estimate
* @returns {string}
*/
protected expirationDateFormatted = (estimate) => {
return this.formattedExpirationDate(estimate);
};
/**
* Retrieves the total amount of the estimate.
* @param estimate
* @returns
*/
protected total(estimate) {
return estimate.amount;
}
/**
* Retrieves the subtotal amount of the estimate.
* @param estimate
* @returns {number}
*/
protected subtotal(estimate) {
return estimate.amount;
}
/**
* Retrieves the formatted total of the estimate.
* @param estimate
* @returns {string}
*/
protected totalFormatted(estimate) {
return this.formatMoney(estimate.amount, {
currencyCode: estimate.currencyCode,
money: true,
});
}
/**
* Retrieves the formatted subtotal of the estimate.
* @param estimate
* @returns {string}
*/
protected subtotalFormatted = (estimate) => {
return this.formatNumber(estimate.amount, { money: false });
};
/**
* Retrieves the estimate entries.
* @param invoice
* @returns {Array}
*/
protected entries = (invoice) => {
return this.item(
invoice.entries,
new GetSaleEstimateMailStateEntryTransformer(),
{
currencyCode: invoice.currencyCode,
},
);
};
/**
* Merges the mail options with the invoice object.
*/
public transform = (object: any) => {
return {
...this.options.mailOptions,
...object,
};
};
}
class GetSaleEstimateMailStateEntryTransformer extends ItemEntryTransformer {
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Item name.
* @param entry
* @returns
*/
public name = (entry) => {
return entry.item.name;
};
public includeAttributes = (): string[] => {
return [
'name',
'quantity',
'unitPrice',
'unitPriceFormatted',
'total',
'totalFormatted',
];
};
}

View File

@@ -0,0 +1,61 @@
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import {
renderEstimateEmailTemplate,
EstimatePaymentEmailProps,
} from '@bigcapital/email-components';
import { Injectable } from '@nestjs/common';
import { GetSaleEstimate } from './GetSaleEstimate.service';
import { GetPdfTemplateService } from '@/modules/PdfTemplate/queries/GetPdfTemplate.service';
import { GetEstimateMailTemplateAttributesTransformer } from './GetEstimateMailTemplateAttributes.transformer';
@Injectable()
export class GetSaleEstimateMailTemplateService {
constructor(
private readonly getEstimateService: GetSaleEstimate,
private readonly transformer: TransformerInjectable,
private readonly getBrandingTemplate: GetPdfTemplateService,
) {}
/**
* Retrieves the mail template attributes of the given estimate.
* Estimate template attributes are composed of the estimate and branding template attributes.
* @param {number} estimateId - Estimate id.
* @returns {Promise<EstimatePaymentEmailProps>}
*/
public async getMailTemplateAttributes(
estimateId: number,
): Promise<EstimatePaymentEmailProps> {
const estimate = await this.getEstimateService.getEstimate(estimateId);
const brandingTemplate = await this.getBrandingTemplate.getPdfTemplate(
estimate.pdfTemplateId,
);
const mailTemplateAttributes = await this.transformer.transform(
estimate,
new GetEstimateMailTemplateAttributesTransformer(),
{
estimate,
brandingTemplate,
},
);
return mailTemplateAttributes;
}
/**
* Rertieves the mail template html content.
* @param {number} tenantId
* @param {number} estimateId
* @param overrideAttributes
* @returns
*/
public async getMailTemplate(
estimateId: number,
overrideAttributes?: Partial<any>,
): Promise<string> {
const attributes = await this.getMailTemplateAttributes(estimateId);
const mergedAttributes = {
...attributes,
...overrideAttributes,
};
return renderEstimateEmailTemplate(mergedAttributes);
}
}

View File

@@ -4,18 +4,17 @@ import { GetSaleEstimate } from './GetSaleEstimate.service';
import { transformEstimateToPdfTemplate } from '../utils';
import { EstimatePdfBrandingAttributes } from '../constants';
import { SaleEstimatePdfTemplate } from '@/modules/SaleInvoices/queries/SaleEstimatePdfTemplate.service';
import { TemplateInjectable } from '@/modules/TemplateInjectable/TemplateInjectable.service';
import { ChromiumlyTenancy } from '@/modules/ChromiumlyTenancy/ChromiumlyTenancy.service';
import { PdfTemplateModel } from '@/modules/PdfTemplate/models/PdfTemplate';
import { events } from '@/common/events/events';
import { SaleEstimate } from '../models/SaleEstimate';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { renderEstimatePaperTemplateHtml } from '@bigcapital/pdf-templates';
@Injectable()
export class GetSaleEstimatePdf {
constructor(
private readonly chromiumlyTenancy: ChromiumlyTenancy,
private readonly templateInjectable: TemplateInjectable,
private readonly getSaleEstimate: GetSaleEstimate,
private readonly estimatePdfTemplate: SaleEstimatePdfTemplate,
private readonly eventPublisher: EventEmitter2,
@@ -29,22 +28,29 @@ export class GetSaleEstimatePdf {
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
) {}
/**
* Retrieve sale estimate html content.
* @param {number} invoiceId -
*/
public async saleEstimateHtml(estimateId: number): Promise<string> {
const brandingAttributes =
await this.getEstimateBrandingAttributes(estimateId);
return renderEstimatePaperTemplateHtml({ ...brandingAttributes });
}
/**
* Retrieve sale invoice pdf content.
* @param {number} tenantId -
* @param {ISaleInvoice} saleInvoice -
* @param {ISaleInvoice} saleInvoice - Sale estimate id.
*/
public async getSaleEstimatePdf(
saleEstimateId: number,
): Promise<[Buffer, string]> {
const filename = await this.getSaleEstimateFilename(saleEstimateId);
const brandingAttributes =
await this.getEstimateBrandingAttributes(saleEstimateId);
const htmlContent = await this.templateInjectable.render(
'modules/estimate-regular',
brandingAttributes,
);
// Retrieves the sale estimate html.
const htmlContent = await this.saleEstimateHtml(saleEstimateId);
const content =
await this.chromiumlyTenancy.convertHtmlContent(htmlContent);
const eventPayload = { saleEstimateId };
@@ -72,7 +78,6 @@ export class GetSaleEstimatePdf {
/**
* Retrieves the given estimate branding attributes.
* @param {number} tenantId - Tenant id.
* @param {number} estimateId - Estimate id.
* @returns {Promise<EstimatePdfBrandingAttributes>}
*/