diff --git a/packages/server/src/api/controllers/PdfTemplates/PdfTemplatesController.ts b/packages/server/src/api/controllers/PdfTemplates/PdfTemplatesController.ts new file mode 100644 index 000000000..2e6821bdb --- /dev/null +++ b/packages/server/src/api/controllers/PdfTemplates/PdfTemplatesController.ts @@ -0,0 +1,129 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { check, param, query } from 'express-validator'; +import { Service, Inject } from 'typedi'; +import BaseController from '@/api/controllers/BaseController'; +import { PdfTemplateApplication } from '@/services/PdfTemplate/PdfTemplateApplication'; + +@Service() +export class PdfTemplatesController extends BaseController { + @Inject() + public pdfTemplateApplication: PdfTemplateApplication; + + /** + * Router constructor method. + */ + public router() { + const router = Router(); + + router.delete( + '/:template_id', + [param('template_id').exists().isInt().toInt()], + this.validationResult, + this.deletePdfTemplate.bind(this) + ); + + router.put( + '/:template_id', + [ + param('template_id').exists().isInt().toInt(), + check('attributes').exists(), + ], + this.validationResult, + this.editPdfTemplate.bind(this) + ); + router.get( + '/:template_id', + [param('template_id').exists().isInt().toInt()], + this.validationResult, + this.getPdfTemplate.bind(this) + ); + router.get('/', this.getPdfTemplates.bind(this)); + router.post( + '/invoices', + [check('template_name').exists(), check('attributes').exists()], + this.validationResult, + this.createPdfInvoiceTemplate.bind(this) + ); + return router; + } + + async createPdfInvoiceTemplate( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { templateName, attributes } = this.matchedBodyData(req); + + try { + const result = await this.pdfTemplateApplication.createPdfTemplate( + tenantId, + templateName, + attributes + ); + return res.status(201).send(result); + } catch (error) { + next(error); + } + } + + async editPdfTemplate(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { template_id: templateId } = req.params; + const { attributes } = this.matchedBodyData(req); + + try { + const result = await this.pdfTemplateApplication.editPdfTemplate( + tenantId, + Number(templateId), + attributes + ); + return res.status(200).send(result); + } catch (error) { + next(error); + } + } + + async deletePdfTemplate(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { template_id: templateId } = req.params; + + try { + await this.pdfTemplateApplication.deletePdfTemplate( + tenantId, + Number(templateId) + ); + return res.status(204).send(); + } catch (error) { + next(error); + } + } + + async getPdfTemplate(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { template_id: templateId } = req.params; + + try { + const template = await this.pdfTemplateApplication.getPdfTemplate( + tenantId, + Number(templateId) + ); + return res.status(200).send(template); + } catch (error) { + next(error); + } + } + + async getPdfTemplates(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + + try { + const templates = await this.pdfTemplateApplication.getPdfTemplates( + tenantId + ); + return res.status(200).send(templates); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index ce4d2093f..c0fc2ef80 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -64,6 +64,7 @@ import { Webhooks } from './controllers/Webhooks/Webhooks'; import { ExportController } from './controllers/Export/ExportController'; import { AttachmentsController } from './controllers/Attachments/AttachmentsController'; import { OneClickDemoController } from './controllers/OneClickDemo/OneClickDemoController'; +import { PdfTemplatesController } from './controllers/PdfTemplates/PdfTemplatesController'; export default () => { const app = Router(); @@ -81,7 +82,7 @@ export default () => { app.use('/jobs', Container.get(Jobs).router()); app.use('/account', Container.get(Account).router()); app.use('/webhooks', Container.get(Webhooks).router()); - app.use('/demo', Container.get(OneClickDemoController).router()) + app.use('/demo', Container.get(OneClickDemoController).router()); // - Dashboard routes. // --------------------------- @@ -147,6 +148,10 @@ export default () => { dashboard.use('/import', Container.get(ImportController).router()); dashboard.use('/export', Container.get(ExportController).router()); dashboard.use('/attachments', Container.get(AttachmentsController).router()); + dashboard.use( + '/pdf_templates', + Container.get(PdfTemplatesController).router() + ); dashboard.use('/', Container.get(ProjectTasksController).router()); dashboard.use('/', Container.get(ProjectTimesController).router()); diff --git a/packages/server/src/database/migrations/20240911112147_create_pdf_templates_table.js b/packages/server/src/database/migrations/20240911112147_create_pdf_templates_table.js new file mode 100644 index 000000000..ce4c96f59 --- /dev/null +++ b/packages/server/src/database/migrations/20240911112147_create_pdf_templates_table.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('pdf_templates', (table) => { + table.increments('id').primary(); + table.text('resource'); + table.text('template_name'); + table.json('attributes'); + table.timestamps(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('pdf_templates'); +}; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 02877491a..3d349f81e 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -68,6 +68,7 @@ import { BankRule } from '@/models/BankRule'; import { BankRuleCondition } from '@/models/BankRuleCondition'; import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction'; import { MatchedBankTransaction } from '@/models/MatchedBankTransaction'; +import { PdfTemplate } from '@/models/PdfTemplate'; export default (knex) => { const models = { @@ -139,6 +140,7 @@ export default (knex) => { BankRuleCondition, RecognizedBankTransaction, MatchedBankTransaction, + PdfTemplate }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/PdfTemplate.ts b/packages/server/src/models/PdfTemplate.ts new file mode 100644 index 000000000..67f53ac9d --- /dev/null +++ b/packages/server/src/models/PdfTemplate.ts @@ -0,0 +1,42 @@ +import TenantModel from 'models/TenantModel'; + +export class PdfTemplate extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'pdf_templates'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + static get jsonSchema() { + return { + type: 'object', + properties: { + id: { type: 'integer' }, + templateName: { type: 'string' }, + attributes: { type: 'object' }, // JSON field definition + }, + }; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/services/PdfTemplate/CreatePdfTemplate.ts b/packages/server/src/services/PdfTemplate/CreatePdfTemplate.ts new file mode 100644 index 000000000..251e67980 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/CreatePdfTemplate.ts @@ -0,0 +1,45 @@ +import { Inject, Service } from 'typedi'; +import { ICreateInvoicePdfTemplateDTO } from './types'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class CreatePdfTemplate { + @Inject() + private tennacy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Creates a new pdf template. + * @param {number} tenantId + * @param {ICreateInvoicePdfTemplateDTO} invoiceTemplateDTO + */ + public createPdfTemplate( + tenantId: number, + templateName: string, + invoiceTemplateDTO: ICreateInvoicePdfTemplateDTO + ) { + const { PdfTemplate } = this.tennacy.models(tenantId); + const resource = 'SaleInvoice'; + const attributes = invoiceTemplateDTO; + + return this.uow.withTransaction(tenantId, async (trx) => { + await PdfTemplate.query(trx).insert({ + templateName, + resource, + attributes, + }); + + await this.eventPublisher.emitAsync(events.pdfTemplate.onInvoiceCreated, { + tenantId, + }); + }); + } +} diff --git a/packages/server/src/services/PdfTemplate/DeletePdfTemplate.ts b/packages/server/src/services/PdfTemplate/DeletePdfTemplate.ts new file mode 100644 index 000000000..eaf4391a5 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/DeletePdfTemplate.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class DeletePdfTemplate { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Deletes a pdf template. + * @param {number} tenantId + * @param {number} templateId + */ + public deletePdfTemplate(tenantId: number, templateId: number) { + const { PdfTemplate } = this.tenancy.models(tenantId); + + return this.uow.withTransaction(tenantId, async (trx) => { + await PdfTemplate.query(trx).deleteById(templateId); + + await this.eventPublisher.emitAsync(events.pdfTemplate.onDeleted, { + tenantId, + templateId, + }); + }); + } +} diff --git a/packages/server/src/services/PdfTemplate/EditPdfTemplate.ts b/packages/server/src/services/PdfTemplate/EditPdfTemplate.ts new file mode 100644 index 000000000..27a57b543 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/EditPdfTemplate.ts @@ -0,0 +1,45 @@ +import { Inject, Service } from 'typedi'; +import { IEditPdfTemplateDTO } from './types'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; + +@Service() +export class EditPdfTemplate { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Edits an existing pdf template. + * @param {number} tenantId + * @param {number} templateId + * @param {IEditPdfTemplateDTO} editTemplateDTO + */ + public editPdfTemplate( + tenantId: number, + templateId: number, + editTemplateDTO: IEditPdfTemplateDTO + ) { + const { PdfTemplate } = this.tenancy.models(tenantId); + + return this.uow.withTransaction(tenantId, async (trx) => { + await PdfTemplate.query(trx) + .patch({ + ...editTemplateDTO, + }) + .where('id', templateId); + + await this.eventPublisher.emitAsync(events.pdfTemplate.onEdited, { + tenantId, + templateId, + }); + }); + } +} diff --git a/packages/server/src/services/PdfTemplate/GetPdfTemplate.ts b/packages/server/src/services/PdfTemplate/GetPdfTemplate.ts new file mode 100644 index 000000000..c41fe651d --- /dev/null +++ b/packages/server/src/services/PdfTemplate/GetPdfTemplate.ts @@ -0,0 +1,29 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; + +@Service() +export class GetPdfTemplate { + @Inject() + private tenancy: HasTenancyService; + + /** + * Retrieves a pdf template by its ID. + * @param {number} tenantId - The ID of the tenant. + * @param {number} templateId - The ID of the pdf template to retrieve. + * @return {Promise} - The retrieved pdf template. + */ + async getPdfTemplate( + tenantId: number, + templateId: number, + trx?: Knex.Transaction + ): Promise { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const template = await PdfTemplate.query(trx) + .findById(templateId) + .throwIfNotFound(); + + return template; + } +} diff --git a/packages/server/src/services/PdfTemplate/GetPdfTemplates.ts b/packages/server/src/services/PdfTemplate/GetPdfTemplates.ts new file mode 100644 index 000000000..ba6d7cdaa --- /dev/null +++ b/packages/server/src/services/PdfTemplate/GetPdfTemplates.ts @@ -0,0 +1,37 @@ +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { GetPdfTemplatesTransformer } from './GetPdfTemplatesTransformer'; +import { Inject, Service } from 'typedi'; + +@Service() +export class GetPdfTemplates { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformInjectable: TransformerInjectable; + + /** + * Retrieves a list of PDF templates for a specified tenant. + * @param {number} tenantId - The ID of the tenant for which to retrieve templates. + * @param {Object} [query] - Optional query parameters to filter the templates. + * @param {string} [query.resource] - The resource type to filter the templates by. + * @returns {Promise} - A promise that resolves to the transformed list of PDF templates. + */ + async getPdfTemplates(tenantId: number, query?: { resource?: string }) { + const { PdfTemplate } = this.tenancy.models(tenantId); + + const templates = await PdfTemplate.query().onBuild((q) => { + if (query?.resource) { + q.where('resource', query?.resource); + } + q.orderBy('createdAt', 'ASC'); + }); + + return this.transformInjectable.transform( + tenantId, + templates, + new GetPdfTemplatesTransformer() + ); + } +} diff --git a/packages/server/src/services/PdfTemplate/GetPdfTemplatesTransformer.ts b/packages/server/src/services/PdfTemplate/GetPdfTemplatesTransformer.ts new file mode 100644 index 000000000..9863281cb --- /dev/null +++ b/packages/server/src/services/PdfTemplate/GetPdfTemplatesTransformer.ts @@ -0,0 +1,13 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetPdfTemplatesTransformer extends Transformer { + // Empty transformer with no additional methods or attributes + + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['attributes']; + }; +} diff --git a/packages/server/src/services/PdfTemplate/PdfTemplateApplication.ts b/packages/server/src/services/PdfTemplate/PdfTemplateApplication.ts new file mode 100644 index 000000000..9f06006d7 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/PdfTemplateApplication.ts @@ -0,0 +1,67 @@ +import { Inject, Service } from 'typedi'; +import { ICreateInvoicePdfTemplateDTO } from './types'; +import { CreatePdfTemplate } from './CreatePdfTemplate'; +import { DeletePdfTemplate } from './DeletePdfTemplate'; +import { GetPdfTemplate } from './GetPdfTemplate'; +import { GetPdfTemplates } from './GetPdfTemplates'; +import { EditPdfTemplate } from './EditPdfTemplate'; + +@Service() +export class PdfTemplateApplication { + @Inject() + private createPdfTemplateService: CreatePdfTemplate; + + @Inject() + private deletePdfTemplateService: DeletePdfTemplate; + + @Inject() + private getPdfTemplateService: GetPdfTemplate; + + @Inject() + private getPdfTemplatesService: GetPdfTemplates; + + @Inject() + private editPdfTemplateService: EditPdfTemplate; + + public async createPdfTemplate( + tenantId: number, + templateName: string, + invoiceTemplateDTO: ICreateInvoicePdfTemplateDTO + ) { + return this.createPdfTemplateService.createPdfTemplate( + tenantId, + templateName, + invoiceTemplateDTO + ); + } + + public async editPdfTemplate( + tenantId: number, + templateId: number, + editTemplateDTO: IEditPdfTemplateDTO + ) { + return this.editPdfTemplateService.editPdfTemplate( + tenantId, + templateId, + editTemplateDTO + ); + } + + public async deletePdfTemplate(tenantId: number, templateId: number) { + return this.deletePdfTemplateService.deletePdfTemplate( + tenantId, + templateId + ); + } + + public async getPdfTemplate(tenantId: number, templateId: number) { + return this.getPdfTemplateService.getPdfTemplate(tenantId, templateId); + } + + public async getPdfTemplates( + tenantId: number, + query?: { resource?: string } + ) { + return this.getPdfTemplatesService.getPdfTemplates(tenantId, query); + } +} diff --git a/packages/server/src/services/PdfTemplate/types.ts b/packages/server/src/services/PdfTemplate/types.ts new file mode 100644 index 000000000..98cf1d214 --- /dev/null +++ b/packages/server/src/services/PdfTemplate/types.ts @@ -0,0 +1,58 @@ +export interface ICreateInvoicePdfTemplateDTO { + // Colors + primaryColor?: string; + secondaryColor?: string; + + // Company Logo + showCompanyLogo?: boolean; + companyLogo?: string; + + // Top details. + showInvoiceNumber?: boolean; + invoiceNumberLabel?: string; + + showDateIssue?: boolean; + dateIssueLabel?: string; + + showDueDate?: boolean; + dueDateLabel?: string; + + // Company name + companyName?: string; + + // Addresses + showBilledFromAddress?: boolean; + showBillingToAddress?: boolean; + billedToLabel?: string; + + // Entries + itemNameLabel?: string; + itemDescriptionLabel?: string; + itemRateLabel?: string; + itemTotalLabel?: string; + + // Totals + showSubtotal?: boolean; + subtotalLabel?: string; + + showDiscount?: boolean; + discountLabel?: string; + + showTaxes?: boolean; + + showTotal?: boolean; + totalLabel?: string; + + paymentMadeLabel?: string; + showPaymentMade?: boolean; + + dueAmountLabel?: string; + showDueAmount?: boolean; + + // Footer paragraphs. + termsConditionsLabel?: string; + showTermsConditions?: boolean; + + statementLabel?: string; + showStatement?: boolean; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index d21ffac98..2a70570cb 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -58,7 +58,7 @@ export default { onSubscriptionSubscribed: 'onSubscriptionSubscribed', onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed', - onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed' + onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed', }, /** @@ -684,4 +684,16 @@ export default { import: { onImportCommitted: 'onImportFileCommitted', }, + + pdfTemplate: { + onCreating: 'onPdfTemplateCreating', + onCreated: 'onPdfTemplateCreated', + + onEditing: 'onPdfTemplateEditing', + onEdited: 'onPdfTemplatedEdited', + + onDeleted: 'onPdfTemplateDeleted', + + onInvoiceCreated: 'onInvoicePdfTemplateCreated', + }, };