From 513980fe3a84e4c81fa7d35ca29557c1b9c1b70a Mon Sep 17 00:00:00 2001
From: mahyargdz
Date: Mon, 14 Jul 2025 15:47:22 +0330
Subject: [PATCH] chore: template
---
src/app.module.ts | 2 +
src/common/enums/message.enum.ts | 23 +++
.../businesses/entities/business.entity.ts | 4 +
src/modules/email/email.module.ts | 10 +-
src/modules/email/services/email.service.ts | 49 ++++-
.../quota-sync/services/quota-sync.service.ts | 4 +-
.../templates/DTO/create-template.dto.ts | 51 +++++
src/modules/templates/DTO/index.ts | 4 +
.../templates/DTO/template-param.dto.ts | 14 ++
.../templates/DTO/template-query.dto.ts | 20 ++
.../templates/DTO/update-template.dto.ts | 44 +++++
.../templates/entities/template.entity.ts | 38 ++++
.../interfaces/structure.interface.ts | 104 ++++++++++
.../repositories/template.repository.ts | 32 ++++
.../services/template-processor.service.ts | 177 ++++++++++++++++++
.../templates/services/templates.service.ts | 131 +++++++++++++
src/modules/templates/templates.controller.ts | 67 +++++++
src/modules/templates/templates.module.ts | 17 ++
src/modules/users/entities/user.entity.ts | 6 +-
19 files changed, 788 insertions(+), 9 deletions(-)
create mode 100644 src/modules/templates/DTO/create-template.dto.ts
create mode 100644 src/modules/templates/DTO/index.ts
create mode 100644 src/modules/templates/DTO/template-param.dto.ts
create mode 100644 src/modules/templates/DTO/template-query.dto.ts
create mode 100644 src/modules/templates/DTO/update-template.dto.ts
create mode 100644 src/modules/templates/entities/template.entity.ts
create mode 100644 src/modules/templates/interfaces/structure.interface.ts
create mode 100644 src/modules/templates/repositories/template.repository.ts
create mode 100644 src/modules/templates/services/template-processor.service.ts
create mode 100644 src/modules/templates/services/templates.service.ts
create mode 100644 src/modules/templates/templates.controller.ts
create mode 100644 src/modules/templates/templates.module.ts
diff --git a/src/app.module.ts b/src/app.module.ts
index 588ab9b..e3e7ba6 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -22,6 +22,7 @@ import { MailServerModule } from "./modules/mail-server/mail-server.module";
import { MailboxModule } from "./modules/mailbox/mailbox.module";
import { QuotaSyncModule } from "./modules/quota-sync/quota-sync.module";
import { EmailSignaturesModule } from "./modules/signatures/email-signatures.module";
+import { TemplatesModule } from "./modules/templates/templates.module";
import { UsersModule } from "./modules/users/users.module";
import { UtilsModule } from "./modules/utils/utils.module";
@@ -44,6 +45,7 @@ import { UtilsModule } from "./modules/utils/utils.module";
MailServerModule,
QuotaSyncModule,
EmailSignaturesModule,
+ TemplatesModule,
],
})
export class AppModule implements NestModule {
diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts
index b726c77..5618c45 100755
--- a/src/common/enums/message.enum.ts
+++ b/src/common/enums/message.enum.ts
@@ -449,3 +449,26 @@ export const enum SignatureMessage {
DELETED = "امضاء با موفقیت حذف شد",
TOGGLE_ACTIVE = "وضعیت امضاء با موفقیت تغییر کرد",
}
+
+export const enum TemplateMessage {
+ TEMPLATE_NOT_FOUND = "قالب یافت نشد",
+ TEMPLATE_CREATED_SUCCESSFULLY = "قالب با موفقیت ایجاد شد",
+ TEMPLATE_UPDATED_SUCCESSFULLY = "قالب با موفقیت به روزرسانی شد",
+ TEMPLATE_DELETED_SUCCESSFULLY = "قالب با موفقیت حذف شد",
+ TEMPLATE_RETRIEVED_SUCCESSFULLY = "قالب با موفقیت دریافت شد",
+ TEMPLATES_LISTED_SUCCESSFULLY = "لیست قالب ها با موفقیت دریافت شد",
+ TEMPLATE_NAME_REQUIRED = "نام قالب الزامی است",
+ TEMPLATE_NAME_STRING = "نام قالب باید یک رشته باشد",
+ TEMPLATE_NAME_MAX_LENGTH = "نام قالب نمیتواند بیشتر از ۲۵۵ کاراکتر باشد",
+ TEMPLATE_STRUCTURE_REQUIRED = "ساختار قالب الزامی است",
+ TEMPLATE_STRUCTURE_VALID = "ساختار قالب باید معتبر باشد",
+ TEMPLATE_RAW_HTML_STRING = "HTML خام باید یک رشته باشد",
+ TEMPLATE_BUSINESS_ID_REQUIRED = "شناسه کسب و کار الزامی است",
+ TEMPLATE_BUSINESS_ID_UUID = "شناسه کسب و کار باید یک UUID معتبر باشد",
+ TEMPLATE_ID_REQUIRED = "شناسه قالب الزامی است",
+ TEMPLATE_ID_UUID = "شناسه قالب باید یک UUID معتبر باشد",
+ TEMPLATE_ACCESS_DENIED = "شما دسترسی به این قالب را ندارید",
+ TEMPLATE_BUSINESS_NOT_FOUND = "کسب و کار مالک قالب یافت نشد",
+ SEARCH_STRING = "نام برای جستجو باید یک رشته باشد",
+ TEMPLATE_SELECTED_SUCCESSFULLY = "وضعیت قالب با موفقیت تغییر کرد",
+}
diff --git a/src/modules/businesses/entities/business.entity.ts b/src/modules/businesses/entities/business.entity.ts
index 7225cd1..e8c9763 100644
--- a/src/modules/businesses/entities/business.entity.ts
+++ b/src/modules/businesses/entities/business.entity.ts
@@ -4,6 +4,7 @@ import { BusinessStaff } from "./business-staff.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Domain } from "../../domains/entities/domain.entity";
import { EmailSignature } from "../../signatures/entities/email-signature.entity";
+import { Template } from "../../templates/entities/template.entity";
import { BusinessRepository } from "../repositories/business.repository";
@Entity({ repository: () => BusinessRepository })
@@ -47,5 +48,8 @@ export class Business extends BaseEntity {
@OneToMany(() => BusinessStaff, (businessStaff) => businessStaff.business)
businessStaffs = new Collection(this);
+ @OneToMany(() => Template, (template) => template.business)
+ templates = new Collection(this);
+
[EntityRepositoryType]?: BusinessRepository;
}
diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts
index 6e1517b..cf7728e 100644
--- a/src/modules/email/email.module.ts
+++ b/src/modules/email/email.module.ts
@@ -1,3 +1,4 @@
+import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
@@ -9,12 +10,15 @@ import { MailboxResolverService } from "./services/mailbox-resolver.service";
import { WebSocketAuthService } from "./services/websocket-auth.service";
import { jwtConfig } from "../../configs/jwt.config";
import { MailServerModule } from "../mail-server/mail-server.module";
+import { TemplateProcessorService } from "../templates/services/template-processor.service";
+import { TemplatesModule } from "../templates/templates.module";
+import { User } from "../users/entities/user.entity";
import { UsersModule } from "../users/users.module";
@Module({
- imports: [MailServerModule, UsersModule, JwtModule.registerAsync(jwtConfig())],
+ imports: [MailServerModule, UsersModule, TemplatesModule, JwtModule.registerAsync(jwtConfig()), MikroOrmModule.forFeature([User])],
controllers: [EmailController],
- providers: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService],
- exports: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService],
+ providers: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService, TemplateProcessorService],
+ exports: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService, TemplateProcessorService],
})
export class EmailModule {}
diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts
index 523a039..70fb0da 100644
--- a/src/modules/email/services/email.service.ts
+++ b/src/modules/email/services/email.service.ts
@@ -5,6 +5,8 @@ import { MailboxResolverService } from "./mailbox-resolver.service";
import { EmailMessage } from "../../../common/enums/message.enum";
import { MailServerService } from "../../mail-server/services/mail-server.service";
import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
+import { TemplateProcessorService } from "../../templates/services/template-processor.service";
+import { UserRepository } from "../../users/repositories/user.repository";
import { BulkActionDto, BulkActionType } from "../DTO/bulk-actions.dto";
import { MessageListQueryDto, SearchMessagesQueryDto } from "../DTO/email-query.dto";
import { SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto";
@@ -19,16 +21,39 @@ export class EmailService {
private readonly mailServerService: MailServerService,
private readonly mailboxResolverService: MailboxResolverService,
private readonly emailGateway: EmailGateway,
- // private readonly userService: UsersService,
+ private readonly templateProcessorService: TemplateProcessorService,
+ private readonly userRepository: UserRepository,
) {}
//########################################################
async sendEmail(userEmailId: string, sendEmailDto: SendEmailDto) {
- // const userEmailId = await this.userService.getUserEmailIdFromId(userId);
-
this.logger.log(`Sending email from user ${userEmailId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`);
try {
+ // Get user's business information
+ const user = await this.userRepository.findOne({ wildduckUserId: userEmailId, deletedAt: null }, { populate: ["business"] });
+
+ if (user && user.business) {
+ this.logger.log(`Processing email through business template for business: ${user.business.id}`);
+
+ // Process email content through business template
+ const processedTemplate = await this.templateProcessorService.processEmailWithTemplate(user.business.id, {
+ text: sendEmailDto.text,
+ html: sendEmailDto.html,
+ subject: sendEmailDto.subject,
+ });
+
+ // Update the DTO with processed content
+ sendEmailDto.html = processedTemplate.html;
+ sendEmailDto.text = processedTemplate.text;
+
+ if (processedTemplate.hasTemplate) {
+ this.logger.log(`Email content processed through template for business: ${user.business.id}`);
+ }
+ } else {
+ this.logger.log(`No business template found for user ${userEmailId}, sending original content`);
+ }
+
const response = await firstValueFrom(this.mailServerService.submission.submitMessage(userEmailId, sendEmailDto));
this.logger.log(`Email sent successfully: ${response.id} (Queue: ${response.queueId})`);
@@ -381,6 +406,24 @@ export class EmailService {
},
};
+ // Process draft content through business template if available
+ const user = await this.userRepository.findOne({ wildduckUserId: userEmailId, deletedAt: null }, { populate: ["business"] });
+
+ if (user && user.business && (updateDraftDto.text || updateDraftDto.html)) {
+ const processedTemplate = await this.templateProcessorService.processEmailWithTemplate(user.business.id, {
+ text: updateDraftDto.text,
+ html: updateDraftDto.html,
+ subject: updateDraftDto.subject,
+ });
+
+ draftData.html = processedTemplate.html;
+ draftData.text = processedTemplate.text;
+
+ if (processedTemplate.hasTemplate) {
+ this.logger.log(`Draft content processed through template for business: ${user.business.id}`);
+ }
+ }
+
const response = await firstValueFrom(this.mailServerService.submission.submitMessage(userEmailId, draftData));
this.logger.log(`Draft updated successfully: ${response.id}`);
diff --git a/src/modules/quota-sync/services/quota-sync.service.ts b/src/modules/quota-sync/services/quota-sync.service.ts
index 16e6c4f..563e69d 100644
--- a/src/modules/quota-sync/services/quota-sync.service.ts
+++ b/src/modules/quota-sync/services/quota-sync.service.ts
@@ -27,7 +27,7 @@ export class QuotaSyncService {
/**
* Sync quota usage every 2 hours
*/
- @Cron(CronExpression.EVERY_10_SECONDS)
+ @Cron(CronExpression.EVERY_2_HOURS)
async syncAllUsersQuota(): Promise {
const em = this.em.fork();
this.logger.log("Starting quota synchronization for all users");
@@ -66,7 +66,7 @@ export class QuotaSyncService {
/**
* Sync business quota usage every 4 hours
*/
- @Cron(CronExpression.EVERY_10_SECONDS)
+ @Cron(CronExpression.EVERY_4_HOURS)
async syncAllBusinessQuotas(): Promise {
const em = this.em.fork();
this.logger.log("Starting business quota synchronization");
diff --git a/src/modules/templates/DTO/create-template.dto.ts b/src/modules/templates/DTO/create-template.dto.ts
new file mode 100644
index 0000000..f5d8c48
--- /dev/null
+++ b/src/modules/templates/DTO/create-template.dto.ts
@@ -0,0 +1,51 @@
+import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
+import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
+
+import { TemplateMessage } from "../../../common/enums/message.enum";
+// import { PersonalityDataType } from "../interfaces/structure.interface";
+
+export class CreateTemplateDto {
+ @IsNotEmpty({ message: TemplateMessage.TEMPLATE_NAME_REQUIRED })
+ @IsString({ message: TemplateMessage.TEMPLATE_NAME_STRING })
+ @MaxLength(255, { message: TemplateMessage.TEMPLATE_NAME_MAX_LENGTH })
+ @ApiProperty({ description: "Template name", example: "Corporate Email Template", maxLength: 255 })
+ name: string;
+
+ @IsNotEmpty({ message: TemplateMessage.TEMPLATE_STRUCTURE_REQUIRED })
+ // @ValidateNested({ message: TemplateMessage.TEMPLATE_STRUCTURE_VALID })
+ // @Type(() => Object)
+ @ApiProperty({
+ description: "Template structure containing header, content, and footer sections",
+ example: {
+ header: {
+ columnsCount: 1,
+ items: [],
+ },
+ content: {
+ columnsCount: 2,
+ items: [],
+ },
+ footer: {
+ columnsCount: 1,
+ items: [],
+ },
+ },
+ })
+ structure: Record;
+
+ @IsOptional()
+ @IsString({ message: TemplateMessage.TEMPLATE_RAW_HTML_STRING })
+ @ApiPropertyOptional({
+ description: "Raw HTML content of the template (optional)",
+ example: "Template Content
",
+ })
+ rawHtml?: string;
+
+ // @IsNotEmpty({ message: "شناسه کسب و کار الزامی است" })
+ // @IsUUID("7", { message: "شناسه کسب و کار باید یک UUID معتبر باشد" })
+ // @ApiProperty({
+ // description: "Business ID that owns this template",
+ // example: "01234567-89ab-cdef-0123-456789abcdef",
+ // })
+ // businessId: string;
+}
diff --git a/src/modules/templates/DTO/index.ts b/src/modules/templates/DTO/index.ts
new file mode 100644
index 0000000..a2c0ddb
--- /dev/null
+++ b/src/modules/templates/DTO/index.ts
@@ -0,0 +1,4 @@
+export { CreateTemplateDto } from "./create-template.dto";
+export { UpdateTemplateDto } from "./update-template.dto";
+export { TemplateQueryDto } from "./template-query.dto";
+export { TemplateParamDto } from "./template-param.dto";
diff --git a/src/modules/templates/DTO/template-param.dto.ts b/src/modules/templates/DTO/template-param.dto.ts
new file mode 100644
index 0000000..4fe44b7
--- /dev/null
+++ b/src/modules/templates/DTO/template-param.dto.ts
@@ -0,0 +1,14 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { IsNotEmpty, IsUUID } from "class-validator";
+
+import { TemplateMessage } from "../../../common/enums/message.enum";
+
+export class TemplateParamDto {
+ @IsNotEmpty({ message: TemplateMessage.TEMPLATE_ID_REQUIRED })
+ @IsUUID("7", { message: TemplateMessage.TEMPLATE_ID_UUID })
+ @ApiProperty({
+ description: "Template ID",
+ example: "01234567-89ab-cdef-0123-456789abcdef",
+ })
+ id: string;
+}
diff --git a/src/modules/templates/DTO/template-query.dto.ts b/src/modules/templates/DTO/template-query.dto.ts
new file mode 100644
index 0000000..d9a3c79
--- /dev/null
+++ b/src/modules/templates/DTO/template-query.dto.ts
@@ -0,0 +1,20 @@
+import { ApiPropertyOptional } from "@nestjs/swagger";
+import { IsOptional, IsString, IsUUID } from "class-validator";
+
+import { PaginationDto } from "../../../common/DTO/pagination.dto";
+import { TemplateMessage } from "../../../common/enums/message.enum";
+
+export class TemplateQueryDto extends PaginationDto {
+ @IsOptional()
+ @IsString({ message: TemplateMessage.SEARCH_STRING })
+ @ApiPropertyOptional({
+ description: "Search by template name",
+ example: "corporate",
+ })
+ search?: string;
+
+ @IsOptional()
+ @IsUUID("7", { message: TemplateMessage.TEMPLATE_BUSINESS_ID_UUID })
+ @ApiPropertyOptional({ description: "Filter by business ID", example: "01234567-89ab-cdef-0123-456789abcdef" })
+ businessId?: string;
+}
diff --git a/src/modules/templates/DTO/update-template.dto.ts b/src/modules/templates/DTO/update-template.dto.ts
new file mode 100644
index 0000000..9705181
--- /dev/null
+++ b/src/modules/templates/DTO/update-template.dto.ts
@@ -0,0 +1,44 @@
+import { ApiPropertyOptional } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsOptional, IsString, MaxLength, ValidateNested } from "class-validator";
+
+import { TemplateMessage } from "../../../common/enums/message.enum";
+import { PersonalityDataType } from "../interfaces/structure.interface";
+
+export class UpdateTemplateDto {
+ @IsOptional()
+ @IsString({ message: TemplateMessage.TEMPLATE_NAME_STRING })
+ @MaxLength(255, { message: TemplateMessage.TEMPLATE_NAME_MAX_LENGTH })
+ @ApiPropertyOptional({ description: "Template name", example: "Updated Corporate Email Template", maxLength: 255 })
+ name?: string;
+
+ @IsOptional()
+ @ValidateNested({ message: TemplateMessage.TEMPLATE_STRUCTURE_VALID })
+ @Type(() => Object)
+ @ApiPropertyOptional({
+ description: "Template structure containing header, content, and footer sections",
+ example: {
+ header: {
+ columnsCount: 1,
+ items: [],
+ },
+ content: {
+ columnsCount: 2,
+ items: [],
+ },
+ footer: {
+ columnsCount: 1,
+ items: [],
+ },
+ },
+ })
+ structure?: PersonalityDataType;
+
+ @IsOptional()
+ @IsString({ message: TemplateMessage.TEMPLATE_RAW_HTML_STRING })
+ @ApiPropertyOptional({
+ description: "Raw HTML content of the template",
+ example: "Updated Template Content
",
+ })
+ rawHtml?: string;
+}
diff --git a/src/modules/templates/entities/template.entity.ts b/src/modules/templates/entities/template.entity.ts
new file mode 100644
index 0000000..18cd98c
--- /dev/null
+++ b/src/modules/templates/entities/template.entity.ts
@@ -0,0 +1,38 @@
+import { Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core";
+import { v7 as uuidv7 } from "uuid";
+
+import { BaseEntity } from "../../../common/entities/base.entity";
+import { Business } from "../../businesses/entities/business.entity";
+import { User } from "../../users/entities/user.entity";
+// import { PersonalityDataType } from "../interfaces/structure.interface";
+import { TemplateRepository } from "../repositories/template.repository";
+
+@Entity({ repository: () => TemplateRepository })
+export class Template extends BaseEntity {
+ @Property({ type: "varchar", length: 255, nullable: false })
+ name: string;
+
+ @Property({ type: "jsonb" })
+ structure: unknown;
+
+ @Property({ type: "text", nullable: true })
+ rawHtml?: string;
+
+ @Property({ type: "uuid", onCreate: () => uuidv7() })
+ currentVersion: string;
+
+ @Property({ type: "boolean", default: false })
+ selected: boolean & Opt;
+
+ //--------------------------------------------------
+
+ @OneToMany(() => User, (user) => user.template)
+ users = new Collection(this);
+
+ //--------------------------------------------------
+
+ @ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" })
+ business!: Business;
+
+ [EntityRepositoryType]?: TemplateRepository;
+}
diff --git a/src/modules/templates/interfaces/structure.interface.ts b/src/modules/templates/interfaces/structure.interface.ts
new file mode 100644
index 0000000..36c1486
--- /dev/null
+++ b/src/modules/templates/interfaces/structure.interface.ts
@@ -0,0 +1,104 @@
+export enum HorizontalAlignment {
+ LEFT = "left",
+ CENTER = "center",
+ RIGHT = "right",
+}
+
+export enum VerticalAlignment {
+ TOP = "top",
+ MIDDLE = "middle",
+ BOTTOM = "bottom",
+}
+
+export enum ButtonSize {
+ SMALL = "small",
+ MEDIUM = "medium",
+ LARGE = "large",
+}
+
+export enum ImageSize {
+ COVER = "cover",
+ CONTAIN = "contain",
+ AUTO = "auto",
+ REPEAT = "repeat",
+ REPEAT_X = "repeat-x",
+ REPEAT_Y = "repeat-y",
+ NO_REPEAT = "no-repeat",
+}
+
+export enum BackgroundSize {
+ COVER = "cover",
+ CONTAIN = "contain",
+ ROUND = "round",
+}
+
+export enum SectionName {
+ HEADER = "header",
+ CONTENT = "content",
+ FOOTER = "footer",
+}
+
+export type PersonalityDataType = {
+ header: SectionType;
+ content: SectionType;
+ footer: SectionType;
+};
+
+export type SectionType = {
+ columnsCount: number;
+ items: SectionItemType[];
+};
+
+export type SectionItemType = {
+ id: string;
+ backgroundColor: string;
+ backgroundImage?: string;
+ texts: TextType[];
+ buttons: ButtonType[];
+ images: ImageType[];
+ alignment?: HorizontalAlignment;
+ verticalAlignment?: VerticalAlignment;
+ style?: {
+ borderRadius?: number;
+ padding?: string;
+ fontFamily?: string;
+ backgroundSize?: string;
+ backgroundRepeat?: string;
+ backgroundPosition?: string;
+ };
+};
+
+export type TextType = {
+ id: string;
+ text: string;
+ fontSize?: number;
+ fontFamily?: string;
+ color?: string;
+ alignment?: HorizontalAlignment;
+ verticalAlignment?: VerticalAlignment;
+};
+
+export type ButtonType = {
+ id: string;
+ text: string;
+ link: string;
+ size: ButtonSize;
+ isBorder: boolean;
+ borderSize: number;
+ textColor: string;
+ backgroundColor: string;
+ borderColor: string;
+ alignment?: HorizontalAlignment;
+ verticalAlignment?: VerticalAlignment;
+};
+
+export type ImageType = {
+ id: string;
+ src: string;
+ alt?: string;
+ size: ImageSize;
+ width?: string;
+ height?: string;
+ alignment?: HorizontalAlignment;
+ verticalAlignment?: VerticalAlignment;
+};
diff --git a/src/modules/templates/repositories/template.repository.ts b/src/modules/templates/repositories/template.repository.ts
new file mode 100644
index 0000000..64bd76d
--- /dev/null
+++ b/src/modules/templates/repositories/template.repository.ts
@@ -0,0 +1,32 @@
+import { EntityRepository, FilterQuery } from "@mikro-orm/postgresql";
+
+import { PaginationUtils } from "../../utils/services/pagination.utils";
+import { TemplateQueryDto } from "../DTO/template-query.dto";
+import { Template } from "../entities/template.entity";
+
+export class TemplateRepository extends EntityRepository {
+ async findAllTemplates(queryDto: TemplateQueryDto, businessId: string) {
+ const { limit, skip } = PaginationUtils(queryDto);
+
+ const query: FilterQuery = {
+ deletedAt: null,
+ business: {
+ id: businessId,
+ },
+ };
+
+ if (queryDto.search) {
+ query.name = { $ilike: `%${queryDto.search}%` };
+ }
+
+ return this.findAndCount(query, { limit, offset: skip, populate: ["business"], exclude: ["rawHtml", "structure"] });
+ }
+
+ //**************************************************** */
+ async findTemplateById(id: string, businessId: string) {
+ const template = await this.findOne({ id, business: { id: businessId }, deletedAt: null }, { populate: ["business"] });
+
+ return template;
+ }
+ //**************************************************** */
+}
diff --git a/src/modules/templates/services/template-processor.service.ts b/src/modules/templates/services/template-processor.service.ts
new file mode 100644
index 0000000..20e4a8a
--- /dev/null
+++ b/src/modules/templates/services/template-processor.service.ts
@@ -0,0 +1,177 @@
+import { Injectable, Logger } from "@nestjs/common";
+
+import { TemplatesService } from "./templates.service";
+// import { PersonalityDataType, SectionItemType, TextType } from "../interfaces/structure.interface";
+
+interface ProcessedTemplateResult {
+ html: string;
+ text: string;
+ hasTemplate: boolean;
+}
+
+@Injectable()
+export class TemplateProcessorService {
+ private readonly logger = new Logger(TemplateProcessorService.name);
+
+ constructor(private readonly templatesService: TemplatesService) {}
+
+ /**
+ * Process email content through business template
+ */
+ async processEmailWithTemplate(
+ businessId: string,
+ emailContent: { text?: string; html?: string; subject?: string },
+ // userId?: string,
+ ): Promise {
+ try {
+ this.logger.log(`Processing email with template for business: ${businessId}`);
+
+ // Get business templates
+ const templates = await this.templatesService.getTemplatesByBusinessId(businessId);
+
+ if (!templates || templates.length === 0) {
+ this.logger.log(`No templates found for business: ${businessId}, using original content`);
+ return {
+ html: emailContent.html || this.convertTextToHtml(emailContent.text || ""),
+ text: emailContent.text || this.stripHtmlTags(emailContent.html || ""),
+ hasTemplate: false,
+ };
+ }
+
+ // Use the first template
+ const template = templates[0];
+
+ this.logger.log(`Using template: ${template.name} for email processing`);
+
+ // Process the template with email content
+ const processedContent = await this.injectContentIntoTemplate(emailContent, template.rawHtml);
+
+ return {
+ html: processedContent.html,
+ text: processedContent.text,
+ hasTemplate: true,
+ };
+ } catch (error) {
+ this.logger.error(`Failed to process email with template for business ${businessId}:`, error);
+
+ // Fallback to original content if template processing fails
+ return {
+ html: emailContent.html || this.convertTextToHtml(emailContent.text || ""),
+ text: emailContent.text || this.stripHtmlTags(emailContent.html || ""),
+ hasTemplate: false,
+ };
+ }
+ }
+
+ /**
+ * Inject email content into template structure
+ */
+ private async injectContentIntoTemplate(
+ // templateStructure: Record,
+ emailContent: { text?: string; html?: string; subject?: string },
+ rawHtml?: string,
+ ): Promise<{ html: string; text: string }> {
+ try {
+ // If template has raw HTML, use it as base and inject content
+ if (rawHtml) {
+ const html = this.injectContentIntoRawHtml(rawHtml, emailContent);
+ return {
+ html,
+ text: this.stripHtmlTags(html),
+ };
+ }
+
+ // Otherwise, build simple HTML with email content
+ const html = this.buildSimpleTemplate(emailContent);
+ return {
+ html,
+ text: this.stripHtmlTags(html),
+ };
+ } catch (error) {
+ this.logger.error("Failed to inject content into template:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Inject email content into raw HTML template
+ */
+ private injectContentIntoRawHtml(rawHtml: string, emailContent: { text?: string; html?: string; subject?: string }): string {
+ let processedHtml = rawHtml;
+
+ // Replace placeholders with actual content
+ const contentToInject = emailContent.html || this.convertTextToHtml(emailContent.text || "");
+
+ // Common placeholder patterns
+ const placeholders = [/\{\{content\}\}/gi, /\{\{message\}\}/gi, /\{\{body\}\}/gi, /\{content\}/gi, /\{message\}/gi, /\{body\}/gi];
+
+ placeholders.forEach((placeholder) => {
+ processedHtml = processedHtml.replace(placeholder, contentToInject);
+ });
+
+ // Replace subject placeholders
+ if (emailContent.subject) {
+ const subjectPlaceholders = [/\{\{subject\}\}/gi, /\{\{title\}\}/gi, /\{subject\}/gi, /\{title\}/gi];
+
+ subjectPlaceholders.forEach((placeholder) => {
+ processedHtml = processedHtml.replace(placeholder, emailContent.subject!);
+ });
+ }
+
+ return processedHtml;
+ }
+
+ /**
+ * Build simple HTML template
+ */
+ private buildSimpleTemplate(emailContent: { text?: string; html?: string; subject?: string }): string {
+ const content = emailContent.html || this.convertTextToHtml(emailContent.text || "");
+
+ return `
+
+
+
+
+
+ ${emailContent.subject || "Email"}
+
+
+
+
+
+
+ `;
+ }
+
+ /**
+ * Convert plain text to HTML
+ */
+ private convertTextToHtml(text: string): string {
+ return text
+ .replace(/\n\n/g, "
")
+ .replace(/\n/g, "
")
+ .replace(/^(.*)$/, "
$1
");
+ }
+
+ /**
+ * Strip HTML tags from content
+ */
+ private stripHtmlTags(html: string): string {
+ return html
+ .replace(/<[^>]*>/g, "")
+ .replace(/ /g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .trim();
+ }
+}
diff --git a/src/modules/templates/services/templates.service.ts b/src/modules/templates/services/templates.service.ts
new file mode 100644
index 0000000..c125882
--- /dev/null
+++ b/src/modules/templates/services/templates.service.ts
@@ -0,0 +1,131 @@
+import { EntityManager } from "@mikro-orm/postgresql";
+import { BadRequestException, Injectable } from "@nestjs/common";
+import dayjs from "dayjs";
+import { v7 as uuidv7 } from "uuid";
+
+import { TemplateMessage } from "../../../common/enums/message.enum";
+import { BusinessRepository } from "../../businesses/repositories/business.repository";
+import { CreateTemplateDto } from "../DTO/create-template.dto";
+import { TemplateQueryDto } from "../DTO/template-query.dto";
+import { UpdateTemplateDto } from "../DTO/update-template.dto";
+import { TemplateRepository } from "../repositories/template.repository";
+
+@Injectable()
+export class TemplatesService {
+ constructor(
+ private readonly templateRepository: TemplateRepository,
+ private readonly businessRepository: BusinessRepository,
+ private readonly em: EntityManager,
+ ) {}
+
+ async createTemplate(createTemplateDto: CreateTemplateDto, businessId: string) {
+ const business = await this.businessRepository.findOne({
+ id: businessId,
+ deletedAt: null,
+ });
+ if (!business) throw new BadRequestException(TemplateMessage.TEMPLATE_BUSINESS_NOT_FOUND);
+
+ const template = this.templateRepository.create({
+ name: createTemplateDto.name,
+ structure: createTemplateDto.structure,
+ rawHtml: createTemplateDto.rawHtml,
+ currentVersion: uuidv7(),
+ business,
+ });
+
+ await this.em.persistAndFlush(template);
+
+ return {
+ message: TemplateMessage.TEMPLATE_CREATED_SUCCESSFULLY,
+ template,
+ };
+ }
+
+ //**************************************************** */
+ async findAllTemplates(queryDto: TemplateQueryDto, businessId: string) {
+ const [templates, count] = await this.templateRepository.findAllTemplates(queryDto, businessId);
+
+ return {
+ templates,
+ count,
+ pagination: true,
+ };
+ }
+
+ //**************************************************** */
+ async findTemplateById(id: string, businessId: string) {
+ const template = await this.templateRepository.findTemplateById(id, businessId);
+
+ if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND);
+
+ return {
+ template,
+ };
+ }
+
+ //**************************************************** */
+ async updateTemplate(id: string, updateTemplateDto: UpdateTemplateDto, businessId: string) {
+ const template = await this.templateRepository.findTemplateById(id, businessId);
+
+ if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND);
+
+ this.templateRepository.assign(template, updateTemplateDto);
+
+ if (updateTemplateDto.structure) {
+ template.currentVersion = uuidv7();
+ }
+
+ await this.em.flush();
+
+ return {
+ message: TemplateMessage.TEMPLATE_UPDATED_SUCCESSFULLY,
+ template,
+ };
+ }
+
+ //**************************************************** */
+ async deleteTemplate(id: string, businessId: string) {
+ const template = await this.templateRepository.findTemplateById(id, businessId);
+
+ if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND);
+
+ template.deletedAt = dayjs().toDate();
+ await this.em.flush();
+
+ return {
+ message: TemplateMessage.TEMPLATE_DELETED_SUCCESSFULLY,
+ };
+ }
+
+ //**************************************************** */
+ async getTemplatesByBusinessId(businessId: string) {
+ return this.templateRepository.find(
+ {
+ business: { id: businessId },
+ deletedAt: null,
+ },
+ {
+ populate: ["business"],
+ },
+ );
+ }
+
+ //**************************************************** */
+ async setSelectedTemplate(id: string, businessId: string) {
+ const template = await this.templateRepository.findTemplateById(id, businessId);
+
+ if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND);
+
+ // First, unselect all templates for this business
+ await this.templateRepository.nativeUpdate({ business: { id: businessId }, deletedAt: null }, { selected: false });
+
+ // Then select the specified template
+ template.selected = true;
+ await this.em.flush();
+
+ return {
+ message: TemplateMessage.TEMPLATE_SELECTED_SUCCESSFULLY,
+ template,
+ };
+ }
+}
diff --git a/src/modules/templates/templates.controller.ts b/src/modules/templates/templates.controller.ts
new file mode 100644
index 0000000..4baaac5
--- /dev/null
+++ b/src/modules/templates/templates.controller.ts
@@ -0,0 +1,67 @@
+import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
+import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
+
+import { CreateTemplateDto } from "./DTO/create-template.dto";
+import { TemplateParamDto } from "./DTO/template-param.dto";
+import { TemplateQueryDto } from "./DTO/template-query.dto";
+import { UpdateTemplateDto } from "./DTO/update-template.dto";
+import { TemplatesService } from "./services/templates.service";
+import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
+import { BusinessDec } from "../../common/decorators/business.decorator";
+import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
+
+@Controller("templates")
+@ApiHeader({ name: "x-business-id", description: "Business ID" })
+@UseInterceptors(BusinessInterceptor)
+@AuthGuards()
+export class TemplatesController {
+ constructor(private readonly templatesService: TemplatesService) {}
+
+ @Post()
+ @ApiOperation({ summary: "Create a new template" })
+ @ApiResponse({ status: 201, description: "Template created successfully" })
+ @ApiResponse({ status: 400, description: "Bad request - invalid data" })
+ @ApiResponse({ status: 404, description: "Business not found" })
+ createTemplate(@Body() createTemplateDto: CreateTemplateDto, @BusinessDec("id") businessId: string) {
+ return this.templatesService.createTemplate(createTemplateDto, businessId);
+ }
+
+ @Get()
+ @ApiOperation({ summary: "Get all templates for business" })
+ @ApiResponse({ status: 200, description: "Templates retrieved successfully" })
+ findAllTemplates(@Query() queryDto: TemplateQueryDto, @BusinessDec("id") businessId: string) {
+ return this.templatesService.findAllTemplates(queryDto, businessId);
+ }
+
+ @Get(":id")
+ @ApiOperation({ summary: "Get template by ID" })
+ @ApiResponse({ status: 200, description: "Template retrieved successfully" })
+ @ApiResponse({ status: 404, description: "Template not found" })
+ findTemplateById(@Param() params: TemplateParamDto, @BusinessDec("id") businessId: string) {
+ return this.templatesService.findTemplateById(params.id, businessId);
+ }
+
+ @Patch(":id")
+ @ApiOperation({ summary: "Update template by ID" })
+ @ApiResponse({ status: 200, description: "Template updated successfully" })
+ @ApiResponse({ status: 404, description: "Template not found" })
+ updateTemplate(@Param() params: TemplateParamDto, @Body() updateTemplateDto: UpdateTemplateDto, @BusinessDec("id") businessId: string) {
+ return this.templatesService.updateTemplate(params.id, updateTemplateDto, businessId);
+ }
+
+ @Delete(":id")
+ @ApiOperation({ summary: "Delete template by ID" })
+ @ApiResponse({ status: 200, description: "Template deleted successfully" })
+ @ApiResponse({ status: 404, description: "Template not found" })
+ deleteTemplate(@Param() params: TemplateParamDto, @BusinessDec("id") businessId: string) {
+ return this.templatesService.deleteTemplate(params.id, businessId);
+ }
+
+ @Post(":id/set-selected")
+ @ApiOperation({ summary: "Set template as selected (unselects all other templates for the business)" })
+ @ApiResponse({ status: 200, description: "Template set as selected successfully" })
+ @ApiResponse({ status: 404, description: "Template not found" })
+ setSelectedTemplate(@Param() params: TemplateParamDto, @BusinessDec("id") businessId: string) {
+ return this.templatesService.setSelectedTemplate(params.id, businessId);
+ }
+}
diff --git a/src/modules/templates/templates.module.ts b/src/modules/templates/templates.module.ts
new file mode 100644
index 0000000..99a50a7
--- /dev/null
+++ b/src/modules/templates/templates.module.ts
@@ -0,0 +1,17 @@
+import { MikroOrmModule } from "@mikro-orm/nestjs";
+import { Module } from "@nestjs/common";
+
+import { Template } from "./entities/template.entity";
+import { TemplateProcessorService } from "./services/template-processor.service";
+import { TemplatesService } from "./services/templates.service";
+import { TemplatesController } from "./templates.controller";
+import { BusinessesModule } from "../businesses/businesses.module";
+import { Business } from "../businesses/entities/business.entity";
+
+@Module({
+ imports: [MikroOrmModule.forFeature([Template, Business]), BusinessesModule],
+ providers: [TemplatesService, TemplateProcessorService],
+ controllers: [TemplatesController],
+ exports: [TemplatesService, TemplateProcessorService],
+})
+export class TemplatesModule {}
diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts
index 7313a26..0d00cfe 100644
--- a/src/modules/users/entities/user.entity.ts
+++ b/src/modules/users/entities/user.entity.ts
@@ -5,6 +5,7 @@ import { Role } from "./role.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Business } from "../../businesses/entities/business.entity";
import { Domain } from "../../domains/entities/domain.entity";
+import { Template } from "../../templates/entities/template.entity";
import { UserRepository } from "../repositories/user.repository";
@Entity({ repository: () => UserRepository })
@@ -52,11 +53,14 @@ export class User extends BaseEntity {
@ManyToOne(() => Role, { deleteRule: "restrict" })
role!: Role;
- @ManyToOne(() => Business, { deleteRule: "restrict" })
+ @ManyToOne(() => Business, { deleteRule: "cascade" })
business!: Business;
@ManyToOne(() => Domain, { deleteRule: "restrict" })
domain!: Domain;
+ @ManyToOne(() => Template, { deleteRule: "restrict", nullable: true })
+ template?: Template;
+
[EntityRepositoryType]?: UserRepository;
}