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 CollectionTemplate 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
")
+ .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; }