From 6c1904cb607929d9d5e08e8c1a8960d79d4d5b5a Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Thu, 31 Jul 2025 11:52:00 +0330 Subject: [PATCH] chore: add new feature for the ai generate template --- EMAIL_POLLING_SYSTEM.md | 279 ------------------ environment.sample | 77 ----- package.json | 1 + pnpm-lock.yaml | 19 ++ src/common/enums/message.enum.ts | 12 + src/configs/ai.config.ts | 213 +++++++++---- .../ai-assistant/DTO/generate-template.dto.ts | 78 +++++ .../ai-assistant/ai-assistant.controller.ts | 7 + .../interfaces/ai-service.interface.ts | 33 +++ .../ai-assistant/interfaces/ai.interface.ts | 60 ++++ .../services/ai-assistant.service.ts | 48 ++- .../ai-assistant/services/ai.service.ts | 102 ++++++- src/modules/email/services/email.service.ts | 4 +- .../interfaces/structure.interface.ts | 92 +++++- .../services/template-processor.service.ts | 49 +-- .../templates/services/templates.service.ts | 5 + src/modules/users/entities/user.entity.ts | 2 + 17 files changed, 622 insertions(+), 459 deletions(-) delete mode 100644 EMAIL_POLLING_SYSTEM.md delete mode 100644 environment.sample create mode 100644 src/modules/ai-assistant/DTO/generate-template.dto.ts create mode 100644 src/modules/ai-assistant/interfaces/ai.interface.ts diff --git a/EMAIL_POLLING_SYSTEM.md b/EMAIL_POLLING_SYSTEM.md deleted file mode 100644 index cb4f77f..0000000 --- a/EMAIL_POLLING_SYSTEM.md +++ /dev/null @@ -1,279 +0,0 @@ -# Email Polling System - -This document describes the email polling system that replaced the webhook-based approach for real-time email notifications. - -## Overview - -The email polling system uses **BullMQ** (Redis-based job queue) to periodically check for new emails when users are connected via WebSocket. This approach is more reliable than webhooks since it doesn't depend on external webhook delivery. - -## Architecture - -### Components - -1. **EmailPollingService** - Manages polling jobs for connected users -2. **EmailPollingProcessor** - Processes email polling jobs and checks for new emails -3. **EmailGateway** - WebSocket gateway that starts/stops polling on user connection/disconnection -4. **BullMQ Queue** - Redis-based job queue for scheduling recurring email checks - -### Flow - -``` -User connects to WebSocket - ↓ -EmailGateway.handleConnection() - ↓ -EmailPollingService.startPollingForUser() - ↓ -BullMQ creates recurring job (every 30 seconds) - ↓ -EmailPollingProcessor.process() - checks for new emails - ↓ -If new emails found → notify via WebSocket - ↓ -User disconnects - ↓ -EmailGateway.handleDisconnect() - ↓ -EmailPollingService.stopPollingForUser() -``` - -## Configuration - -### Environment Variables - -Required environment variables (see `environment.sample`): - -```bash -# Redis for BullMQ -REDIS_URI=redis://localhost:6379 - -# WildDuck Mail Server -WILDDUCK_SERVER=http://localhost:8080 -WILDDUCK_API_TOKEN=your-wildduck-access-token - -# Email Polling (optional) -EMAIL_POLLING_INTERVAL=30000 # 30 seconds -EMAIL_POLLING_ENABLED=true -``` - -### BullMQ Configuration - -The system uses the existing BullMQ configuration from `src/configs/bullmq.config.ts`: - -```typescript -export function bullMqConfig(): SharedBullAsyncConfiguration { - return { - connection: { - url: configService.getOrThrow("REDIS_URI"), - }, - prefix: "dmail", - defaultJobOptions: { - removeOnComplete: true, - removeOnFail: false, - attempts: 5, - }, - }; -} -``` - -## Implementation Details - -### Email Polling Service - -Located at `src/modules/email-utils/services/email-polling.service.ts` - -**Key Methods:** - -- `startPollingForUser(userId, connectionId)` - Start polling when user connects -- `stopPollingForUser(userId)` - Stop polling when user disconnects -- `getPollingStats()` - Get statistics about active polling jobs - -**Job Configuration:** - -```typescript -const job = await this.emailPollingQueue.add(`email-poll-${userId}`, jobData, { - repeat: { - every: 30000, // 30 seconds interval - }, - removeOnComplete: 5, - removeOnFail: 10, - attempts: 3, - backoff: { - type: "exponential", - delay: 2000, - }, -}); -``` - -### Email Polling Processor - -Located at `src/modules/email-utils/queue/email-polling.processor.ts` - -**Processing Logic:** - -1. Check if user is still connected (skip if disconnected) -2. Get user's mailbox information -3. Query WildDuck API for recent emails since last check -4. For each new email: - - Send WebSocket notification via `EmailGateway.notifyNewEmail()` - - Update unread count via `EmailGateway.notifyUnreadCountUpdate()` -5. Update job data with new timestamp for next run - -### WebSocket Integration - -The `EmailGateway` automatically manages polling: - -```typescript -// On user connection -await this.emailPollingService.startPollingForUser(user.id, client.id); - -// On user disconnection -await this.emailPollingService.stopPollingForUser(userInfo.userId); -``` - -## WebSocket Events - -The system emits these WebSocket events when new emails are found: - -### `new_email` Event - -```typescript -{ - userId: string; - messageId: number; - subject: string; - from: { name?: string; address: string }; - to: Array<{ name?: string; address: string }>; - hasAttachments: boolean; - timestamp: string; - mailboxId: string; - mailboxName: string; - isRead: boolean; -} -``` - -### `unread_count_updated` Event - -```typescript -{ - userId: string; - totalUnread: number; - mailboxCounts: Record; - count: number; - timestamp: string; -} -``` - -## Monitoring & Debugging - -### Check Active Polling Jobs - -```typescript -const stats = emailPollingService.getPollingStats(); -console.log({ - activeJobs: stats.activeJobs, - userIds: stats.userIds, - connections: stats.connections, -}); -``` - -### BullMQ Dashboard - -You can use BullMQ's web dashboard to monitor jobs: - -```bash -npm install -g @bull-board/express -# Then access the dashboard at http://localhost:3000/admin/queues -``` - -### Logs - -The system logs important events: - -``` -[EmailPollingService] Started email polling for user 12345 with job ID: email-poll-12345 -[EmailPollingProcessor] Found 3 new emails for user 12345 -[EmailPollingProcessor] Notified user 12345 of 3 new emails -[EmailPollingService] Stopped email polling for user 12345 -``` - -## Performance Considerations - -### Polling Interval - -- Default: 30 seconds -- Adjustable via `EMAIL_POLLING_INTERVAL` environment variable -- Balance between real-time updates and API load - -### Job Management - -- Jobs are automatically cleaned up when users disconnect -- Failed jobs are retried up to 3 times with exponential backoff -- Completed jobs are kept (last 5) for debugging - -### Resource Usage - -- Each connected user creates one recurring BullMQ job -- Jobs only run when users are connected (checked at start of each job) -- Redis is used for job queue storage - -## Advantages over Webhooks - -1. **Reliability** - No dependency on external webhook delivery -2. **Connection Awareness** - Only polls when users are actually connected -3. **Error Handling** - Built-in retry mechanism with exponential backoff -4. **Monitoring** - Full visibility into job status and performance -5. **Scalability** - Redis-based queue can handle high loads -6. **Control** - Can adjust polling frequency per user if needed - -## Migration from Webhooks - -The old webhook system has been completely removed: - -**Deleted Files:** - -- `src/modules/email-utils/controllers/webhook.controller.ts` -- `src/modules/email-utils/services/webhook-management.service.ts` -- `src/modules/email-utils/services/webhook-email-events.service.ts` -- `src/modules/email/interfaces/webhook-events.interface.ts` - -**Updated Files:** - -- `src/modules/email-utils/email-utils.module.ts` - Added BullMQ and new services -- `src/modules/email-gateway/email.gateway.ts` - Added polling integration -- `src/modules/email-gateway/email-gateway.module.ts` - Added EmailUtilsModule import - -## Troubleshooting - -### Common Issues - -**Issue: Jobs not running** - -- Check Redis connection -- Verify `REDIS_URI` environment variable -- Check BullMQ logs - -**Issue: Users not receiving notifications** - -- Verify user is connected to WebSocket -- Check if polling job is active: `emailPollingService.getPollingStats()` -- Verify WildDuck API credentials and server URL - -**Issue: High Redis memory usage** - -- Adjust `removeOnComplete` and `removeOnFail` values -- Consider shorter polling intervals for fewer concurrent jobs - -**Issue: API rate limiting** - -- Increase polling interval -- Implement user-specific rate limiting -- Use WildDuck API bulk endpoints if available - -## Future Enhancements - -1. **Dynamic Polling Intervals** - Adjust frequency based on user activity -2. **Priority Users** - More frequent polling for premium users -3. **Email Filtering** - Only notify for emails matching user preferences -4. **Batch Processing** - Process multiple users in single API calls -5. **Caching** - Cache recent emails to reduce API calls diff --git a/environment.sample b/environment.sample deleted file mode 100644 index b1bf15d..0000000 --- a/environment.sample +++ /dev/null @@ -1,77 +0,0 @@ -# Database Configuration -DATABASE_URL=postgresql://username:password@localhost:5432/dmail_db - -# Redis Configuration (for BullMQ) -REDIS_URI=redis://localhost:6379 - -# JWT Configuration -JWT_SECRET=your-super-secret-jwt-key-here -JWT_EXPIRES_IN=7d - -# Mail Server Configuration (WildDuck API) -WILDDUCK_SERVER=http://localhost:8080 -WILDDUCK_API_TOKEN=your-wildduck-access-token-here - -# Email Polling Configuration -EMAIL_POLLING_INTERVAL=30000 # 30 seconds (in milliseconds) -EMAIL_POLLING_ENABLED=true - -# WebSocket Configuration -WS_CORS_ORIGIN=http://localhost:3000 -WS_TRANSPORTS=websocket,polling - -# Application Configuration -NODE_ENV=development -PORT=3000 -APP_NAME=dmail-api -LOG_LEVEL=debug - -# AWS S3 Configuration (for file uploads) -AWS_REGION=us-east-1 -AWS_ACCESS_KEY_ID=your-aws-access-key -AWS_SECRET_ACCESS_KEY=your-aws-secret-key -AWS_S3_BUCKET=your-s3-bucket-name - -# Notification Configuration (for push notifications) -VAPID_PUBLIC_KEY=your-vapid-public-key -VAPID_PRIVATE_KEY=your-vapid-private-key -VAPID_SUBJECT=mailto:admin@yourapp.com - -# Rate Limiting -RATE_LIMIT_TTL=60000 # 1 minute -RATE_LIMIT_LIMIT=100 # 100 requests per minute per IP - -# Cache Configuration -CACHE_TTL=300000 # 5 minutes - -# Business Configuration -BUSINESS_QUOTA_DEFAULT=1000 -BUSINESS_QUOTA_PREMIUM=10000 - -# AI Assistant Configuration (OpenAI) -OPENAI_API_KEY=your-openai-api-key -OPENAI_MODEL=gpt-3.5-turbo - -# CORS Configuration -CORS_ORIGINS=http://localhost:3000,https://yourapp.com - -# Email Templates -EMAIL_TEMPLATE_DIR=./templates -EMAIL_FROM_ADDRESS=noreply@yourapp.com -EMAIL_FROM_NAME=Your App Name - -# Monitoring & Logging -SENTRY_DSN=your-sentry-dsn-here -LOG_FORMAT=combined -LOG_FILE_PATH=./logs/app.log - -# Development Settings -SWAGGER_ENABLED=true -DEBUG=false - -# Queue Configuration -QUEUE_REDIS_URL=redis://localhost:6379 -QUEUE_PREFIX=dmail -QUEUE_DEFAULT_JOB_OPTIONS_REMOVE_ON_COMPLETE=true -QUEUE_DEFAULT_JOB_OPTIONS_REMOVE_ON_FAIL=false -QUEUE_DEFAULT_JOB_OPTIONS_ATTEMPTS=5 \ No newline at end of file diff --git a/package.json b/package.json index eb5b737..59d26cc 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "fastify": "^5.4.0", "nodemailer": "^7.0.4", "openai": "^5.10.2", + "parse-json": "^8.3.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c62085e..f8309ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: openai: specifier: ^5.10.2 version: 5.10.2 + parse-json: + specifier: ^8.3.0 + version: 8.3.0 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -3918,6 +3921,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + index-to-position@1.1.0: + resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==} + engines: {node: '>=18'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -5114,6 +5121,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -11028,6 +11039,8 @@ snapshots: imurmurhash@0.1.4: {} + index-to-position@1.1.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -12637,6 +12650,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.27.1 + index-to-position: 1.1.0 + type-fest: 4.41.0 + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index ce877bc..441f908 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -574,6 +574,18 @@ export const enum AiMessage { NO_RESPONSE_FROM_AI_MODEL = "خطا در دریافت پاسخ از مدل AI", MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = "ایمیل یافت نشد یا دسترسی آن را ندارید", EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = "محتوای ایمیل خالی است یا قابل استخراج نیست", + DESCRIPTION_IS_REQUIRED = "شرح و هدف قالب الزامی است", + DESCRIPTION_MUST_BE_A_STRING = "شرح قالب باید یک رشته باشد", + DESCRIPTION_MUST_BE_AT_LEAST_10_CHARACTERS = "شرح قالب باید حداقل ۱۰ کاراکتر باشد", + DESCRIPTION_MUST_BE_LESS_THAN_500_CHARACTERS = "شرح قالب نمی‌تواند بیشتر از ۵۰۰ کاراکتر باشد", + THEME_MUST_BE_A_VALID_VALUE = "تم قالب باید یکی از مقادیر مجاز باشد", + STYLE_MUST_BE_A_VALID_VALUE = "سبک قالب باید یکی از مقادیر مجاز باشد", + PURPOSE_MUST_BE_A_VALID_VALUE = "هدف قالب باید یکی از مقادیر مجاز باشد", + TEMPLATE_NAME_MUST_BE_A_STRING = "نام قالب باید یک رشته باشد", + TEMPLATE_NAME_MUST_BE_LESS_THAN_100_CHARACTERS = "نام قالب نمی‌تواند بیشتر از ۱۰۰ کاراکتر باشد", + PREFERRED_COLORS_MUST_BE_A_STRING = "رنگ‌های ترجیحی باید یک رشته باشد", + PREFERRED_COLORS_MUST_BE_LESS_THAN_200_CHARACTERS = "رنگ‌های ترجیحی نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد", + PREFERRED_COLORS_IS_REQUIRED = "رنگ‌های ترجیحی الزامی است", } export const enum TwoFactorMessage { diff --git a/src/configs/ai.config.ts b/src/configs/ai.config.ts index 50a5623..4be9cfb 100644 --- a/src/configs/ai.config.ts +++ b/src/configs/ai.config.ts @@ -2,51 +2,7 @@ import { FactoryProvider } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AI_CONFIG } from "../common/constants"; - -export interface AIConfig { - baseUrl: string; - apiKey: string; - defaultModel: string; - temperature: number; - maxTokens: number; - timeout: number; - farsiPrompts: FarsiPrompts; -} - -export interface FarsiPrompts { - summarization: { - systemPrompt: string; - promptTemplate: { - intro: string; - subjectLabel: string; - fromLabel: string; - toLabel: string; - dateLabel: string; - contentLabel: string; - attachmentsLabel: string; - analysisRequirements: string; - summaryLengthLabel: string; - toneLabel: string; - }; - }; - writingAssistance: { - systemPrompt: string; - promptTemplate: { - intro: string; - mainMessageLabel: string; - emailDetailsLabel: string; - purposeLabel: string; - toneLabel: string; - lengthLabel: string; - keyPointsLabel: string; - contextLabel: string; - originalSubjectLabel: string; - originalSenderLabel: string; - originalContentLabel: string; - additionalInstructionsLabel: string; - }; - }; -} +import { AIConfig, FarsiPrompts } from "../modules/ai-assistant/interfaces/ai.interface"; const FARSI_PROMPTS: FarsiPrompts = { summarization: { @@ -118,17 +74,174 @@ const FARSI_PROMPTS: FarsiPrompts = { additionalInstructionsLabel: "دستورالعمل‌های اضافی:", }, }, + templateGeneration: { + systemPrompt: `شما یک طراح ماهر قالب‌های ایمیل حرفه‌ای هستید که قالب‌هایی در سطح شرکت‌های بزرگ مانند Figma، Stripe، Airbnb، و Apple طراحی می‌کنید. وظیفه شما تولید قالب‌های قدرتمند، جذاب و کاملاً حرفه‌ای است. + +**الزامات ساختاری - بسیار مهم:** +- قالب باید دقیقاً از ساختار PersonalityDataType پیروی کند +- ساختار شامل سه بخش است: header, content, footer +- هر بخش دارای columnsCount (عدد) و items (آرایه) است +- هر item دارای id منحصر به فرد (رشته) است +- استفاده از enums مشخص شده الزامی است + +**Enums مجاز:** +- HorizontalAlignment: "left" | "center" | "right" +- VerticalAlignment: "top" | "middle" | "bottom" +- ButtonSize: "small" | "medium" | "large" +- ImageSize: "cover" | "contain" | "auto" | "repeat" | "repeat-x" | "repeat-y" | "no-repeat" +- BackgroundSize: "cover" | "contain" | "round" +- BackgroundPosition: "center center" | "top left" | "top center" | "top right" | "center left" | "center right" | "bottom left" | "bottom center" | "bottom right" +- SocialNetworkType: "facebook" | "twitter" | "instagram" | "linkedin" | "youtube" | "telegram" | "whatsapp" | "tiktok" | "pinterest" | "snapchat" | "github" | "discord" +- SocialIconSize: "small" | "medium" | "large" +- SocialIconStyle: "circle" | "square" | "rounded" + +**ساختار دقیق هر SectionItemType:** +{ + "id": "unique-string-id", + "backgroundColor": "#hex-color", + "backgroundImage": "optional-url", + "texts": [ + { + "id": "text-unique-id", + "text": "محتوای متن", + "fontSize": number, + "fontFamily": "font-name", + "color": "#hex-color", + "alignment": "left|center|right", + "verticalAlignment": "top|middle|bottom" + } + ], + "buttons": [ + { + "id": "button-unique-id", + "text": "متن دکمه", + "link": "url", + "size": "small|medium|large", + "isBorder": boolean, + "borderSize": number, + "textColor": "#hex-color", + "backgroundColor": "#hex-color", + "borderColor": "#hex-color", + "alignment": "left|center|right", + "verticalAlignment": "top|middle|bottom" + } + ], + "images": [ + { + "id": "image-unique-id", + "src": "https://real-image-url-from-internet.com", + "alt": "توضیح تصویر", + "size": "cover|contain|auto|repeat|repeat-x|repeat-y|no-repeat", + "width": "100%", + "height": "auto", + "alignment": "left|center|right", + "verticalAlignment": "top|middle|bottom" + } + ], + "socials": [ + { + "id": "social-unique-id", + "networkType": "facebook|twitter|instagram|linkedin|youtube|telegram|whatsapp|tiktok|pinterest|snapchat|github|discord", + "link": "https://social-link.com", + "size": "small|medium|large", + "style": "circle|square|rounded", + "color": "#hex-color", + "alignment": "left|center|right", + "verticalAlignment": "top|middle|bottom" + } + ], + "alignment": "left|center|right", + "verticalAlignment": "top|middle|bottom", + "style": { + "borderRadius": number, + "padding": "css-padding-value", + "fontFamily": "font-family-name", + "backgroundSize": "cover|contain|round", + "backgroundRepeat": "no-repeat|repeat|repeat-x|repeat-y", + "backgroundPosition": "center center|top left|..." + } +} + +**الزامات طراحی حرفه‌ای (مانند شرکت‌های بزرگ):** +1. **Header Excellence**: + - لوگو با اندازه مناسب (حداکثر ارتفاع 60px، عرض متناسب) + - لوگو نباید خیلی بزرگ یا غیرحرفه‌ای باشد + - ناوبری، پیام‌های مهم، شخصی‌سازی +2. **Content Power**: + - محتوای جذاب، CTA قوی + - تصاویر واقعی از اینترنت (نه placeholder) + - layout تمیز و حرفه‌ای +3. **Footer Excellence - بسیار مهم**: + - طراحی فوق‌العاده زیبا، صاف و نرم (smooth) + - آیکون‌های شبکه‌های اجتماعی زیبا و هماهنگ (حداقل 4-6 شبکه) + - اطلاعات تماس شرکت با فونت مناسب + - لینک‌های مهم (Privacy Policy, Terms, Unsubscribe) با فاصله‌گذاری مناسب + - آدرس شرکت با طراحی زیبا + - کپی‌رایت و برندینگ حرفه‌ای + - padding و margin های مناسب برای ظاهر نرم و صاف + - رنگ‌بندی هارمونیک و ملایم +4. **Professional Design**: + - رنگ‌های هماهنگ و مدرن + - typography حرفه‌ای + - spacing و padding مناسب + - responsive design + - accessibility standards + +**استراتژی طراحی:** +- از الگوهای شرکت‌های معتبر الهام بگیرید +- **حتماً تصاویر واقعی از اینترنت استفاده کنید** (مثل https://images.unsplash.com، https://via.placeholder.com، یا سایت‌های معتبر تصویر) +- محتوای placeholder واقع‌گرایانه استفاده کنید +- CTA های قوی و جذاب طراحی کنید +- Social proof و trust signals اضافه کنید +- Mobile-first approach +- Brand consistency +- **لوگو را در اندازه مناسب و حرفه‌ای نگه دارید** +- **Footer را بسیار زیبا، نرم و حرفه‌ای طراحی کنید** + +**فرمت پاسخ JSON:** +{ + "templateName": "نام قالب حرفه‌ای", + "structure": { + "header": { + "columnsCount": number, + "items": [SectionItemType...] + }, + "content": { + "columnsCount": number, + "items": [SectionItemType...] + }, + "footer": { + "columnsCount": number, + "items": [SectionItemType...] // حتماً شامل socials و اطلاعات شرکت + } + }, + "description": "توضیح کامل قالب و ویژگی‌های حرفه‌ای آن", +}`, + promptTemplate: { + intro: "لطفاً یک قالب ایمیل حرفه‌ای در سطح شرکت‌های بزرگ با مشخصات زیر و ساختار دقیق PersonalityDataType طراحی کنید:", + descriptionLabel: "شرح و هدف قالب:", + themeLabel: "تم قالب:", + styleLabel: "سبک قالب:", + purposeLabel: "هدف استفاده:", + colorsLabel: "رنگ‌های ترجیحی:", + templateNameLabel: "نام پیشنهادی:", + requirementsLabel: "الزامات حرفه‌ای:", + structureRequirements: "- ساختار دقیق PersonalityDataType با header/content/footer کامل", + htmlRequirements: "- HTML حرفه‌ای، responsive و سازگار با email clients", + designRequirements: "- استفاده از socials در footer، طراحی مانند شرکت‌های بزرگ، CTA قوی", + }, + }, }; export const aiConfigProvider: FactoryProvider = { provide: AI_CONFIG, useFactory: (configService: ConfigService): AIConfig => ({ - baseUrl: configService.get("AI_BASE_URL", "https://api.openai.com/v1"), - apiKey: configService.get("AI_API_KEY", ""), - defaultModel: configService.get("AI_DEFAULT_MODEL", "gpt-3.5-turbo"), + baseUrl: configService.getOrThrow("AI_BASE_URL"), + apiKey: configService.getOrThrow("AI_API_KEY"), + defaultModel: configService.getOrThrow("AI_DEFAULT_MODEL"), temperature: configService.get("AI_TEMPERATURE", 0.7), - maxTokens: configService.get("AI_MAX_TOKENS", 2000), - timeout: configService.get("AI_TIMEOUT", 30000), + maxTokens: configService.get("AI_MAX_TOKENS", 5000), + timeout: configService.get("AI_TIMEOUT", 80000), farsiPrompts: FARSI_PROMPTS, }), inject: [ConfigService], diff --git a/src/modules/ai-assistant/DTO/generate-template.dto.ts b/src/modules/ai-assistant/DTO/generate-template.dto.ts new file mode 100644 index 0000000..a2ee3ae --- /dev/null +++ b/src/modules/ai-assistant/DTO/generate-template.dto.ts @@ -0,0 +1,78 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from "class-validator"; + +import { AiMessage } from "../../../common/enums/message.enum"; + +export enum TemplateThemeEnum { + MODERN = "modern", + CLASSIC = "classic", + MINIMAL = "minimal", + CORPORATE = "corporate", + CREATIVE = "creative", + NEWSLETTER = "newsletter", +} + +export enum TemplateStyleEnum { + PROFESSIONAL = "professional", + CASUAL = "casual", + ELEGANT = "elegant", + BOLD = "bold", + CLEAN = "clean", +} + +export enum TemplatePurposeEnum { + MARKETING = "marketing", + NEWSLETTER = "newsletter", + ANNOUNCEMENT = "announcement", + WELCOME = "welcome", + TRANSACTIONAL = "transactional", + PROMOTIONAL = "promotional", + INVITATION = "invitation", + GENERAL = "general", +} + +export class GenerateTemplateDto { + @IsNotEmpty({ message: AiMessage.DESCRIPTION_IS_REQUIRED }) + @IsString({ message: AiMessage.DESCRIPTION_MUST_BE_A_STRING }) + @MinLength(10, { message: AiMessage.DESCRIPTION_MUST_BE_AT_LEAST_10_CHARACTERS }) + @MaxLength(1000, { message: AiMessage.DESCRIPTION_MUST_BE_LESS_THAN_500_CHARACTERS }) + @ApiProperty({ + description: "Description of the template purpose and content requirements", + example: "Create a professional newsletter template for announcing new products and company updates", + minLength: 10, + maxLength: 500, + }) + description: string; + + @IsOptional() + @IsEnum(TemplateThemeEnum) + @ApiPropertyOptional({ enum: TemplateThemeEnum, description: "Visual theme of the template", example: TemplateThemeEnum.MODERN }) + theme?: TemplateThemeEnum; + + @IsOptional() + @IsEnum(TemplateStyleEnum) + @ApiPropertyOptional({ enum: TemplateStyleEnum, description: "Style approach for the template", example: TemplateStyleEnum.PROFESSIONAL }) + style?: TemplateStyleEnum; + + @IsOptional() + @IsEnum(TemplatePurposeEnum) + @ApiPropertyOptional({ enum: TemplatePurposeEnum, description: "Primary purpose of the template", example: TemplatePurposeEnum.NEWSLETTER }) + purpose?: TemplatePurposeEnum; + + @IsOptional() + @IsString({ message: AiMessage.TEMPLATE_NAME_MUST_BE_A_STRING }) + @MaxLength(100, { message: AiMessage.TEMPLATE_NAME_MUST_BE_LESS_THAN_100_CHARACTERS }) + @ApiPropertyOptional({ description: "Suggested name for the template", example: "Monthly Product Newsletter", maxLength: 100 }) + templateName?: string; + + @IsOptional() + @IsNotEmpty({ message: AiMessage.PREFERRED_COLORS_IS_REQUIRED }) + @IsString({ message: AiMessage.PREFERRED_COLORS_MUST_BE_A_STRING }) + @MaxLength(200, { message: AiMessage.PREFERRED_COLORS_MUST_BE_LESS_THAN_200_CHARACTERS }) + @ApiPropertyOptional({ + description: "Preferred color scheme or specific colors to use", + example: "Blue and white corporate colors, #1e3a8a, #ffffff", + maxLength: 200, + }) + preferredColors?: string; +} diff --git a/src/modules/ai-assistant/ai-assistant.controller.ts b/src/modules/ai-assistant/ai-assistant.controller.ts index eaa97d7..104b802 100644 --- a/src/modules/ai-assistant/ai-assistant.controller.ts +++ b/src/modules/ai-assistant/ai-assistant.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Post } from "@nestjs/common"; import { ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { GenerateTemplateDto } from "./DTO/generate-template.dto"; import { SummarizeEmailDto } from "./DTO/summarize-email.dto"; import { WriteEmailAssistanceDto } from "./DTO/write-email-assistance.dto"; import { AiAssistantService } from "./services/ai-assistant.service"; @@ -30,4 +31,10 @@ export class AiAssistantController { async assistEmailWriting(@Body() writeDto: WriteEmailAssistanceDto, @UserDec("wildduckUserId") wildduckUserId: string) { return this.aiAssistantService.assistEmailWriting(writeDto, wildduckUserId); } + + @Post("generate-template") + @ApiOperation({ summary: "Generate an email template using AI" }) + async generateTemplate(@Body() generateTemplateDto: GenerateTemplateDto) { + return this.aiAssistantService.generateTemplate(generateTemplateDto); + } } diff --git a/src/modules/ai-assistant/interfaces/ai-service.interface.ts b/src/modules/ai-assistant/interfaces/ai-service.interface.ts index c4db202..d41ecfc 100644 --- a/src/modules/ai-assistant/interfaces/ai-service.interface.ts +++ b/src/modules/ai-assistant/interfaces/ai-service.interface.ts @@ -1,5 +1,7 @@ import { PriorityEnum } from "../../email/enums/email-header.enum"; import { MessageAttachment } from "../../mail-server/interfaces/messages-response.interface"; +import { PersonalityDataType } from "../../templates/interfaces/structure.interface"; +import { TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto"; import { LanguageEnum, SentimentEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum"; export interface IEmailData { @@ -18,6 +20,36 @@ export interface SummarizationOptions { language?: LanguageEnum; } +export interface TemplateGenerationOptions { + theme?: TemplateThemeEnum; + style?: TemplateStyleEnum; + purpose?: TemplatePurposeEnum; + templateName?: string; + preferredColors?: string; + language?: LanguageEnum; +} + +export interface ITemplateGenerationRequest { + description: string; + options: TemplateGenerationOptions; +} + +export interface AITemplateGenerationResponse { + templateName: string; + structure: PersonalityDataType; + rawHtml: string; + description: string; + designPrinciples: string[]; + suggestedImprovements: string[]; + colorScheme: { + primary: string; + secondary: string; + accent?: string; + background: string; + text: string; + }; +} + export interface AISummaryResponse { summary: string; keyPoints: string[]; @@ -37,4 +69,5 @@ export interface AIWritingResponse { export interface AIServiceInterface { summarizeEmail(emailData: IEmailData, options: SummarizationOptions): Promise; assistEmailWriting(prompt: string): Promise; + generateTemplate(request: ITemplateGenerationRequest): Promise; } diff --git a/src/modules/ai-assistant/interfaces/ai.interface.ts b/src/modules/ai-assistant/interfaces/ai.interface.ts new file mode 100644 index 0000000..b462927 --- /dev/null +++ b/src/modules/ai-assistant/interfaces/ai.interface.ts @@ -0,0 +1,60 @@ +export interface AIConfig { + baseUrl: string; + apiKey: string; + defaultModel: string; + temperature: number; + maxTokens: number; + timeout: number; + farsiPrompts: FarsiPrompts; +} + +export interface FarsiPrompts { + summarization: { + systemPrompt: string; + promptTemplate: { + intro: string; + subjectLabel: string; + fromLabel: string; + toLabel: string; + dateLabel: string; + contentLabel: string; + attachmentsLabel: string; + analysisRequirements: string; + summaryLengthLabel: string; + toneLabel: string; + }; + }; + writingAssistance: { + systemPrompt: string; + promptTemplate: { + intro: string; + mainMessageLabel: string; + emailDetailsLabel: string; + purposeLabel: string; + toneLabel: string; + lengthLabel: string; + keyPointsLabel: string; + contextLabel: string; + originalSubjectLabel: string; + originalSenderLabel: string; + originalContentLabel: string; + additionalInstructionsLabel: string; + }; + }; + templateGeneration: { + systemPrompt: string; + promptTemplate: { + intro: string; + descriptionLabel: string; + themeLabel: string; + styleLabel: string; + purposeLabel: string; + colorsLabel: string; + templateNameLabel: string; + requirementsLabel: string; + structureRequirements: string; + htmlRequirements: string; + designRequirements: string; + }; + }; +} diff --git a/src/modules/ai-assistant/services/ai-assistant.service.ts b/src/modules/ai-assistant/services/ai-assistant.service.ts index 5a2f81e..09617c4 100644 --- a/src/modules/ai-assistant/services/ai-assistant.service.ts +++ b/src/modules/ai-assistant/services/ai-assistant.service.ts @@ -5,10 +5,11 @@ import { AiService } from "./ai.service"; import { AiMessage } from "../../../common/enums/message.enum"; import { MessageDetails } from "../../mail-server/interfaces/messages-response.interface"; import { WildDuckMessagesService } from "../../mail-server/services/wildduck-messages.service"; +import { GenerateTemplateDto, TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto"; import { SummarizeEmailDto } from "../DTO/summarize-email.dto"; import { WriteEmailAssistanceDto } from "../DTO/write-email-assistance.dto"; import { SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum"; -import { IEmailData, SummarizationOptions } from "../interfaces/ai-service.interface"; +import { IEmailData, ITemplateGenerationRequest, SummarizationOptions, TemplateGenerationOptions } from "../interfaces/ai-service.interface"; @Injectable() export class AiAssistantService { @@ -37,6 +38,51 @@ export class AiAssistantService { return { assistance }; } + //************************************************* */ + async generateTemplate(generateTemplateDto: GenerateTemplateDto) { + if (!generateTemplateDto.description?.trim()) { + throw new BadRequestException(AiMessage.DESCRIPTION_IS_REQUIRED); + } + + const templateRequest: ITemplateGenerationRequest = { + description: generateTemplateDto.description.trim(), + options: this.buildTemplateGenerationOptions(generateTemplateDto), + }; + + const aiResponse = await this.aiService.generateTemplate(templateRequest); + + return { + template: { + name: aiResponse.templateName, + description: aiResponse.description, + structure: aiResponse.structure, + rawHtml: aiResponse.rawHtml, + colorScheme: aiResponse.colorScheme, + }, + designInsights: { + designPrinciples: aiResponse.designPrinciples, + suggestedImprovements: aiResponse.suggestedImprovements, + }, + metadata: { + theme: generateTemplateDto.theme || TemplateThemeEnum.MODERN, + style: generateTemplateDto.style || TemplateStyleEnum.PROFESSIONAL, + purpose: generateTemplateDto.purpose || TemplatePurposeEnum.GENERAL, + generatedAt: new Date().toISOString(), + }, + }; + } + + //************************************************* */ + private buildTemplateGenerationOptions(dto: GenerateTemplateDto): TemplateGenerationOptions { + return { + theme: dto.theme, + style: dto.style, + purpose: dto.purpose, + templateName: dto.templateName, + preferredColors: dto.preferredColors, + }; + } + //************************************************* */ private extractEmailData(message: MessageDetails) { return { diff --git a/src/modules/ai-assistant/services/ai.service.ts b/src/modules/ai-assistant/services/ai.service.ts index 1174499..feaca3f 100644 --- a/src/modules/ai-assistant/services/ai.service.ts +++ b/src/modules/ai-assistant/services/ai.service.ts @@ -1,14 +1,25 @@ -import { Inject, Injectable, InternalServerErrorException } from "@nestjs/common"; +import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; import OpenAI from "openai"; +import parseJson from "parse-json"; import { AI_CONFIG } from "../../../common/constants"; import { AiMessage } from "../../../common/enums/message.enum"; -import { AIConfig } from "../../../configs/ai.config"; import { EmailPurposeEnum, LanguageEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum"; -import { AIServiceInterface, AISummaryResponse, AIWritingResponse, IEmailData, SummarizationOptions } from "../interfaces/ai-service.interface"; +import { + AIServiceInterface, + AISummaryResponse, + AITemplateGenerationResponse, + AIWritingResponse, + IEmailData, + ITemplateGenerationRequest, + SummarizationOptions, +} from "../interfaces/ai-service.interface"; +import { AIConfig } from "../interfaces/ai.interface"; @Injectable() export class AiService implements AIServiceInterface { + private readonly logger = new Logger(AiService.name); + private readonly openai: OpenAI; private readonly language: LanguageEnum = LanguageEnum.PERSIAN; private readonly defaultTone: SummaryToneEnum = SummaryToneEnum.PROFESSIONAL; @@ -47,7 +58,7 @@ export class AiService implements AIServiceInterface { const response = completion.choices[0]?.message?.content; if (!response) throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL); - const parsedResponse = JSON.parse(response) as AISummaryResponse; + const parsedResponse = this.parseJsonResponse(response); return parsedResponse; } @@ -74,10 +85,51 @@ export class AiService implements AIServiceInterface { const response = completion.choices[0]?.message?.content; if (!response) throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL); - const parsedResponse = JSON.parse(response) as AIWritingResponse; + const parsedResponse = this.parseJsonResponse(response); return parsedResponse; } + + //************************************************* */ + async generateTemplate(request: ITemplateGenerationRequest): Promise { + const prompt = this.buildTemplateGenerationPrompt(request); + + const completion = await this.openai.chat.completions.create({ + model: this.aiConfig.defaultModel, + messages: [ + { + role: "system", + content: this.getTemplateGenerationSystemPrompt(), + }, + { + role: "user", + content: prompt, + }, + ], + temperature: this.aiConfig.temperature, + max_tokens: this.aiConfig.maxTokens, + response_format: { type: "json_object" }, + }); + + const response = completion.choices[0]?.message?.content; + if (!response) throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL); + + const parsedResponse = this.parseJsonResponse(response); + + return parsedResponse; + } + //************************************************* */ + + private parseJsonResponse(response: string): T { + try { + return parseJson(response) as T; + } catch (error) { + this.logger.error("error parsing json response", `${error instanceof Error ? error.message : "Unknown error"}`); + return response as T; + // throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL); + } + } + //************************************************* */ private getSummarizationSystemPrompt(options: SummarizationOptions): string { const language = options.language || this.language; @@ -98,6 +150,11 @@ export class AiService implements AIServiceInterface { .replace("{language}", language); } + //************************************************* */ + private getTemplateGenerationSystemPrompt(): string { + return this.aiConfig.farsiPrompts.templateGeneration.systemPrompt; + } + //************************************************* */ private buildSummarizationPrompt(emailData: IEmailData, options: SummarizationOptions): string { const template = this.aiConfig.farsiPrompts.summarization.promptTemplate; @@ -119,4 +176,39 @@ export class AiService implements AIServiceInterface { return prompt; } + + //************************************************* */ + private buildTemplateGenerationPrompt(request: ITemplateGenerationRequest): string { + const template = this.aiConfig.farsiPrompts.templateGeneration.promptTemplate; + let prompt = `${template.intro}\n\n`; + + prompt += `${template.descriptionLabel} ${request.description}\n\n`; + + if (request.options.theme) { + prompt += `${template.themeLabel} ${request.options.theme}\n`; + } + + if (request.options.style) { + prompt += `${template.styleLabel} ${request.options.style}\n`; + } + + if (request.options.purpose) { + prompt += `${template.purposeLabel} ${request.options.purpose}\n`; + } + + if (request.options.preferredColors) { + prompt += `${template.colorsLabel} ${request.options.preferredColors}\n`; + } + + if (request.options.templateName) { + prompt += `${template.templateNameLabel} ${request.options.templateName}\n`; + } + + prompt += `\n${template.requirementsLabel}\n`; + prompt += `${template.structureRequirements}\n`; + prompt += `${template.htmlRequirements}\n`; + prompt += `${template.designRequirements}\n`; + + return prompt; + } } diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts index 5c51e72..4ca6fc1 100644 --- a/src/modules/email/services/email.service.ts +++ b/src/modules/email/services/email.service.ts @@ -17,7 +17,7 @@ import { UserRepository } from "../../users/repositories/user.repository"; import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant"; import { BulkActionDto, BulkActionType } from "../DTO/bulk-actions.dto"; import { MessageListQueryDto, SearchMessagesQueryDto } from "../DTO/email-query.dto"; -import { EmailRecipientDto, EmailType, SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto"; +import { EmailRecipientDto, SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto"; import { PriorityEnum } from "../enums/email-header.enum"; import { IEmailMap } from "../interfaces/email-map.interface"; @@ -38,8 +38,6 @@ export class EmailService { //######################################################## async sendEmail(wildduckUserId: string, userId: string, sendEmailDto: SendEmailDto) { - this.logger.log(`Sending ${EmailType.GENERAL} email from user ${wildduckUserId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`); - const user = await this.userRepository.findOne({ wildduckUserId, deletedAt: null }, { populate: ["business"] }); if (!user) throw new BadRequestException(EmailMessage.USER_NOT_FOUND); this.logger.log(`Processing email through business template for business: ${user.business.id}`); diff --git a/src/modules/templates/interfaces/structure.interface.ts b/src/modules/templates/interfaces/structure.interface.ts index 3cae048..e71a253 100644 --- a/src/modules/templates/interfaces/structure.interface.ts +++ b/src/modules/templates/interfaces/structure.interface.ts @@ -32,17 +32,67 @@ export enum BackgroundSize { ROUND = "round", } +export enum BackgroundPosition { + CENTER = "center center", + TOP_LEFT = "top left", + TOP_CENTER = "top center", + TOP_RIGHT = "top right", + CENTER_LEFT = "center left", + CENTER_RIGHT = "center right", + BOTTOM_LEFT = "bottom left", + BOTTOM_CENTER = "bottom center", + BOTTOM_RIGHT = "bottom right", +} + export enum SectionName { HEADER = "header", CONTENT = "content", FOOTER = "footer", } -export type PersonalityDataType = { - header: SectionType; - content: SectionType; - footer: SectionType; -}; +// New enums for element selection +export enum ElementType { + TEXT = "text", + BUTTON = "button", + IMAGE = "image", + SOCIAL = "social", +} + +// Social network types +export enum SocialNetworkType { + FACEBOOK = "facebook", + TWITTER = "twitter", + INSTAGRAM = "instagram", + LINKEDIN = "linkedin", + YOUTUBE = "youtube", + TELEGRAM = "telegram", + WHATSAPP = "whatsapp", + TIKTOK = "tiktok", + PINTEREST = "pinterest", + SNAPCHAT = "snapchat", + GITHUB = "github", + DISCORD = "discord", +} + +export enum SocialIconSize { + SMALL = "small", + MEDIUM = "medium", + LARGE = "large", +} + +export enum SocialIconStyle { + CIRCLE = "circle", + SQUARE = "square", + ROUNDED = "rounded", +} + +// New type for selected element +export type SelectedElement = { + type: ElementType; + elementId: string; + itemId: string; + sectionKey: keyof PersonalityDataType; +} | null; export type SectionType = { columnsCount: number; @@ -56,6 +106,7 @@ export type SectionItemType = { texts: TextType[]; buttons: ButtonType[]; images: ImageType[]; + socials: SocialType[]; alignment?: HorizontalAlignment; verticalAlignment?: VerticalAlignment; style?: { @@ -103,14 +154,33 @@ export type ImageType = { verticalAlignment?: VerticalAlignment; }; +export type SocialType = { + id: string; + networkType: SocialNetworkType; + link: string; + size: SocialIconSize; + style: SocialIconStyle; + color?: string; + alignment?: HorizontalAlignment; + verticalAlignment?: VerticalAlignment; +}; + +export type TemplateType = { + name: string; + rawHtml: string; + structure: PersonalityDataType; + thumbnailUrl: string; +}; + +//main type for the template structure +export type PersonalityDataType = { + header: SectionType; + content: SectionType; + footer: SectionType; +}; + export interface EmailContent { text?: string; html?: string; subject?: string; } - -export interface ProcessedEmailResult { - content: string; - isHtml: boolean; - hasTemplate: boolean; -} diff --git a/src/modules/templates/services/template-processor.service.ts b/src/modules/templates/services/template-processor.service.ts index 3c33d50..baf966e 100644 --- a/src/modules/templates/services/template-processor.service.ts +++ b/src/modules/templates/services/template-processor.service.ts @@ -1,52 +1,36 @@ -import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { TemplatesService } from "./templates.service"; import { UserMessage } from "../../../common/enums/message.enum"; import { UserRepository } from "../../users/repositories/user.repository"; -import { EmailContent, ProcessedEmailResult } from "../interfaces/structure.interface"; +import { EmailContent } from "../interfaces/structure.interface"; @Injectable() export class TemplateProcessorService { - private readonly logger = new Logger(TemplateProcessorService.name); - constructor( private readonly templatesService: TemplatesService, private readonly userRepository: UserRepository, ) {} //**************************************************** */ - async processEmailContent(businessId: string, userId: string, emailContent: EmailContent): Promise { - try { - // Get selected template for the business - const selectedTemplate = await this.getSelectedTemplate(businessId, userId); + async processEmailContent(businessId: string, userId: string, emailContent: EmailContent) { + const selectedTemplate = await this.getSelectedTemplate(businessId, userId); - if (!selectedTemplate) { - // No template - return original text content - return { - content: emailContent.text || emailContent.html || "", - isHtml: false, - hasTemplate: false, - }; - } - - // Process with template - const processedContent = this.injectContentIntoTemplate(selectedTemplate.rawHtml, emailContent); - - return { - content: processedContent, - isHtml: true, - hasTemplate: true, - }; - } catch (error) { - this.logger.error(`Failed to process email for business ${businessId}:`, error); - - // Fallback to original content + if (!selectedTemplate) { return { content: emailContent.text || emailContent.html || "", isHtml: false, hasTemplate: false, }; } + + const processedContent = await this.injectContentIntoTemplate(selectedTemplate.rawHtml, emailContent); + + return { + content: processedContent, + isHtml: true, + hasTemplate: true, + }; } //**************************************************** */ @@ -54,14 +38,13 @@ export class TemplateProcessorService { const user = await this.userRepository.findOne({ id: userId, business: { id: businessId }, deletedAt: null }, { populate: ["template"] }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); - if (user.template) return user.template; + if (user.template && user.template.rawHtml) return user.template; - const templates = await this.templatesService.getTemplatesByBusinessId(businessId); - return templates.find((template) => template.selected) || null; + return this.templatesService.getSelectedTemplateByBusinessId(businessId); } //**************************************************** */ - private injectContentIntoTemplate(templateHtml: string | undefined, emailContent: EmailContent): string { + private async injectContentIntoTemplate(templateHtml: string | undefined, emailContent: EmailContent) { if (!templateHtml) { return emailContent.text || emailContent.html || ""; } diff --git a/src/modules/templates/services/templates.service.ts b/src/modules/templates/services/templates.service.ts index 505b933..eee90db 100644 --- a/src/modules/templates/services/templates.service.ts +++ b/src/modules/templates/services/templates.service.ts @@ -102,6 +102,11 @@ export class TemplatesService { return this.templateRepository.find({ business: { id: businessId }, deletedAt: null }, { populate: ["business"] }); } + //**************************************************** */ + async getSelectedTemplateByBusinessId(businessId: string) { + return this.templateRepository.findOne({ business: { id: businessId }, deletedAt: null, selected: true }, { populate: ["business"] }); + } + //**************************************************** */ async setSelectedTemplate(id: string, businessId: string) { const template = await this.templateRepository.findTemplateByIdWithoutRawHtmlAndStructure(id, businessId); diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 3cd0357..16e6b4a 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -67,6 +67,8 @@ export class User extends BaseEntity { @OneToMany(() => RefreshToken, (token) => token.user) refreshTokens = new Collection(this); + //========================= + @ManyToOne(() => Role, { deleteRule: "restrict" }) role!: Role;